From c9bb77ad7dc731ab5fef90aa2593c0c7664bab5d Mon Sep 17 00:00:00 2001 From: Anne Erdtsieck Date: Wed, 22 Jul 2026 11:27:37 +0200 Subject: [PATCH 1/2] feat: Cache LINQ query plans per filter shape (fixes #5013) 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 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(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. --- src/LinqTests/query_plan_cache_Tests.cs | 155 +++++++++++ src/Marten/Linq/Caching/CachedLinqPlan.cs | 31 +++ .../Linq/Caching/ExpressionShapeVisitor.cs | 245 ++++++++++++++++++ src/Marten/Linq/Caching/LinqPlanRecorder.cs | 196 ++++++++++++++ .../Linq/Caching/SlotReplacingVisitor.cs | 37 +++ src/Marten/Linq/MartenLinqQueryProvider.cs | 107 +++++++- src/Marten/Linq/MartenLinqQueryable.cs | 15 ++ src/Marten/Linq/QueryPlanCache.cs | 103 ++++++++ src/Marten/Linq/QueryPlanCaching.cs | 23 ++ src/Marten/LinqParsing.cs | 18 ++ src/Marten/QueryableExtensions.cs | 17 ++ 11 files changed, 946 insertions(+), 1 deletion(-) create mode 100644 src/LinqTests/query_plan_cache_Tests.cs create mode 100644 src/Marten/Linq/Caching/CachedLinqPlan.cs create mode 100644 src/Marten/Linq/Caching/ExpressionShapeVisitor.cs create mode 100644 src/Marten/Linq/Caching/LinqPlanRecorder.cs create mode 100644 src/Marten/Linq/Caching/SlotReplacingVisitor.cs create mode 100644 src/Marten/Linq/QueryPlanCache.cs create mode 100644 src/Marten/Linq/QueryPlanCaching.cs diff --git a/src/LinqTests/query_plan_cache_Tests.cs b/src/LinqTests/query_plan_cache_Tests.cs new file mode 100644 index 0000000000..0410d0ca52 --- /dev/null +++ b/src/LinqTests/query_plan_cache_Tests.cs @@ -0,0 +1,155 @@ +using System.Linq; +using System.Threading.Tasks; +using Marten; +using Marten.Linq; +using Marten.Linq.Caching; +using Marten.Testing.Documents; +using Marten.Testing.Harness; +using Shouldly; + +namespace LinqTests; + +public class query_plan_cache_Tests: OneOffConfigurationsContext +{ + [Fact] + public void same_shape_different_values_produces_same_key() + { + int number1 = 1; + int number2 = 2; + + var (expr1, _) = whereExpression(number1); + var (expr2, _) = whereExpression(number2); + + var shape1 = ExpressionShapeVisitor.Analyze(expr1); + var shape2 = ExpressionShapeVisitor.Analyze(expr2); + + shape1.IsSupported.ShouldBeTrue(); + shape2.IsSupported.ShouldBeTrue(); + + shape1.BuildKey(typeof(Target), typeof(Target)) + .ShouldBe(shape2.BuildKey(typeof(Target), typeof(Target))); + } + + [Fact] + public void different_shapes_produce_different_keys() + { + var (whereExpr, _) = whereExpression(1); + var (skipTakeExpr, _) = skipTakeExpression(1, 2); + + var shape1 = ExpressionShapeVisitor.Analyze(whereExpr); + var shape2 = ExpressionShapeVisitor.Analyze(skipTakeExpr); + + shape1.IsSupported.ShouldBeTrue(); + shape2.IsSupported.ShouldBeTrue(); + + shape1.BuildKey(typeof(Target), typeof(Target)) + .ShouldNotBe(shape2.BuildKey(typeof(Target), typeof(Target))); + } + + [Fact] + public void different_filter_combinations_produce_different_keys() + { + var (whereOnlyExpr, _) = whereExpression(1); + var (whereAndOrderExpr, _) = whereAndOrderExpression(1); + + var shape1 = ExpressionShapeVisitor.Analyze(whereOnlyExpr); + var shape2 = ExpressionShapeVisitor.Analyze(whereAndOrderExpr); + + shape1.BuildKey(typeof(Target), typeof(Target)) + .ShouldNotBe(shape2.BuildKey(typeof(Target), typeof(Target))); + } + + [Fact] + public void unsupported_shapes_are_flagged() + { + IQueryable queryable = new Target[0].AsQueryable(); + var expr = queryable.Where(x => x.String.StartsWith("A")).Expression; + + var shape = ExpressionShapeVisitor.Analyze(expr); + + shape.IsSupported.ShouldBeFalse(); + } + + [Fact] + public async Task cached_plan_produces_same_results_as_uncached() + { + StoreOptions(_ => _.Linq.EnableQueryPlanCaching()); + + using var session = theStore.LightweightSession(); + session.Store(new Target { Number = 1, String = "one" }); + session.Store(new Target { Number = 2, String = "two" }); + session.Store(new Target { Number = 3, String = "three" }); + await session.SaveChangesAsync(); + + var uncached = await session.Query().Where(x => x.Number == 2).ToListAsync(); + + var cached = await session.Query().Where(x => x.Number == 2) + .ToListAsync(default, QueryPlanCaching.Cached); + + cached.Count.ShouldBe(uncached.Count); + cached.Single().Number.ShouldBe(2); + + // Different filter value, same shape -- should hit the cache on the second call. + var cached2 = await session.Query().Where(x => x.Number == 3) + .ToListAsync(default, QueryPlanCaching.Cached); + + cached2.Single().Number.ShouldBe(3); + + theStore.Options.Linq.QueryPlanCache.Count.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task cache_respects_max_entries() + { + StoreOptions(_ => _.Linq.QueryPlanCache = QueryPlanCache.PerShape(maxEntries: 1)); + + using var session = theStore.LightweightSession(); + session.Store(new Target { Number = 1 }); + await session.SaveChangesAsync(); + + await session.Query().Where(x => x.Number == 1) + .ToListAsync(default, QueryPlanCaching.Cached); + await session.Query().Where(x => x.String == "abc") + .ToListAsync(default, QueryPlanCaching.Cached); + + theStore.Options.Linq.QueryPlanCache.Count.ShouldBeLessThanOrEqualTo(1); + } + + [Fact] + public async Task opt_in_configuration_is_required() + { + // No StoreOptions() call enabling the cache -- QueryPlanCache.Disabled is the default. + using var session = theStore.LightweightSession(); + session.Store(new Target { Number = 1 }); + await session.SaveChangesAsync(); + + theStore.Options.Linq.QueryPlanCache.Enabled.ShouldBeFalse(); + + var results = await session.Query().Where(x => x.Number == 1) + .ToListAsync(default, QueryPlanCaching.Cached); + + results.Single().Number.ShouldBe(1); + theStore.Options.Linq.QueryPlanCache.Count.ShouldBe(0); + } + + private static (System.Linq.Expressions.Expression, int) whereExpression(int number) + { + IQueryable queryable = new Target[0].AsQueryable(); + var expr = queryable.Where(x => x.Number == number).Expression; + return (expr, number); + } + + private static (System.Linq.Expressions.Expression, int) whereAndOrderExpression(int number) + { + IQueryable queryable = new Target[0].AsQueryable(); + var expr = queryable.Where(x => x.Number == number).OrderBy(x => x.Number).Expression; + return (expr, number); + } + + private static (System.Linq.Expressions.Expression, int) skipTakeExpression(int skip, int take) + { + IQueryable queryable = new Target[0].AsQueryable(); + var expr = queryable.Skip(skip).Take(take).Expression; + return (expr, skip); + } +} diff --git a/src/Marten/Linq/Caching/CachedLinqPlan.cs b/src/Marten/Linq/Caching/CachedLinqPlan.cs new file mode 100644 index 0000000000..3c8a805f18 --- /dev/null +++ b/src/Marten/Linq/Caching/CachedLinqPlan.cs @@ -0,0 +1,31 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Marten.Linq.QueryHandlers; +using Npgsql; + +namespace Marten.Linq.Caching; + +/// +/// A single compiled-and-cached LINQ plan for one structural query shape: the query +/// handler used to read results (safe to reuse across calls -- selectors only depend +/// on the shape, never on parameter values), a template with +/// unique sentinel parameter values recorded once, and the mapping from each parameter +/// back to the slot (position in ) that +/// supplied it. +/// +internal sealed class CachedLinqPlan +{ + public required IQueryHandler Handler { get; init; } + public required NpgsqlBatch TemplateBatch { get; init; } + public required IReadOnlyList Bindings { get; init; } + public required IReadOnlyList DocumentTypes { get; init; } +} + +/// +/// Records that the parameter at within the batch command +/// at should be rebound, on every cache hit, from the +/// current value of the expression slot at . +/// +internal readonly record struct SlotBinding(int CommandIndex, int ParameterIndex, int SlotIndex); + diff --git a/src/Marten/Linq/Caching/ExpressionShapeVisitor.cs b/src/Marten/Linq/Caching/ExpressionShapeVisitor.cs new file mode 100644 index 0000000000..5f9e703729 --- /dev/null +++ b/src/Marten/Linq/Caching/ExpressionShapeVisitor.cs @@ -0,0 +1,245 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Security.Cryptography; +using System.Text; +using Marten.Linq.Parsing; + +namespace Marten.Linq.Caching; + +/// +/// Walks a LINQ expression tree (the Where/OrderBy/Skip/Take/Select chain built by +/// ) to compute a structural "shape" key that is +/// stable across calls with the same query shape but different captured (closure) +/// values, and collects the ordered list of value-bearing "slot" sub-expressions -- +/// the leaves that vary between calls (constants, and closure-captured member +/// accesses). +/// +/// +/// This is deliberately conservative. Anything that isn't one of a small allow-listed +/// set of node types (or a non-allow-listed method call such as StartsWith(), +/// Include(), Stats(), GroupBy(), SelectMany(), custom SQL, +/// etc.) flips to false, which means the query plan +/// cache () will never attempt to cache it. Correctness +/// always wins over caching a shape we can't fully reason about. +/// +internal sealed class ExpressionShapeVisitor: ExpressionVisitor +{ + private static readonly HashSet AllowedQueryableMethods = new() + { + nameof(Queryable.Where), + nameof(Queryable.OrderBy), + nameof(Queryable.OrderByDescending), + nameof(Queryable.ThenBy), + nameof(Queryable.ThenByDescending), + nameof(Queryable.Skip), + nameof(Queryable.Take), + nameof(Queryable.Select) + }; + + private readonly StringBuilder _shape = new(); + private readonly List _slots = new(); + private bool _supported = true; + + private ExpressionShapeVisitor() + { + } + + /// + /// False if the expression uses anything outside of the narrow MVP scope (simple + /// Where comparisons, OrderBy/ThenBy, Skip/Take, Select of members) that the plan + /// cache doesn't know how to safely replay. + /// + public bool IsSupported => _supported; + + /// + /// The ordered list of sub-expressions -- closures and literal constants -- whose + /// runtime values may differ between calls sharing this shape. + /// + public IReadOnlyList Slots => _slots; + + public static ExpressionShapeVisitor Analyze(Expression expression) + { + var visitor = new ExpressionShapeVisitor(); + + try + { + visitor.Visit(expression); + } + catch (Exception) + { + // Any failure while walking the tree means we don't understand this shape + // well enough to cache it. Fall back to the always-correct, uncached path. + visitor._supported = false; + } + + return visitor; + } + + /// + /// Builds a stable cache key for this shape, scoped to the source document type and + /// the requested result type (so two structurally identical shapes over different + /// document types never collide). + /// + public string BuildKey(Type sourceType, Type resultType) + { + var text = sourceType.FullName + "|" + resultType.FullName + "|" + _shape; + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(text)); + return Convert.ToHexString(bytes); + } + + public override Expression? Visit(Expression? node) + { + if (node == null) + { + return null; + } + + if (!_supported) + { + return node; + } + + switch (node.NodeType) + { + case ExpressionType.Call: + case ExpressionType.Lambda: + case ExpressionType.Equal: + case ExpressionType.NotEqual: + case ExpressionType.GreaterThan: + case ExpressionType.GreaterThanOrEqual: + case ExpressionType.LessThan: + case ExpressionType.LessThanOrEqual: + case ExpressionType.AndAlso: + case ExpressionType.OrElse: + case ExpressionType.And: + case ExpressionType.Or: + case ExpressionType.Convert: + case ExpressionType.ConvertChecked: + case ExpressionType.Not: + case ExpressionType.Negate: + case ExpressionType.Quote: + case ExpressionType.MemberAccess: + case ExpressionType.Constant: + case ExpressionType.Parameter: + case ExpressionType.New: + return base.Visit(node); + default: + // Anything else (conditional, indexers, member init, invoke, etc.) is out + // of scope for the MVP cache. + _supported = false; + return node; + } + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if ((node.Method.DeclaringType == typeof(Queryable) || node.Method.DeclaringType == typeof(Enumerable)) + && AllowedQueryableMethods.Contains(node.Method.Name)) + { + _shape.Append("M(").Append(node.Method.Name); + foreach (var t in node.Method.GetGenericArguments()) + { + _shape.Append('<').Append(t.FullName).Append('>'); + } + + _shape.Append(':'); + foreach (var argument in node.Arguments) + { + Visit(argument); + _shape.Append(','); + } + + _shape.Append(')'); + return node; + } + + // Anything else -- Contains(), StartsWith(), Include(), Stats(), custom SQL, + // GroupBy, SelectMany, etc. -- is out of scope for the MVP cache. + _supported = false; + return node; + } + + protected override Expression VisitLambda(Expression node) + { + _shape.Append("λ("); + Visit(node.Body); + _shape.Append(')'); + return node; + } + + protected override Expression VisitBinary(BinaryExpression node) + { + _shape.Append(node.NodeType).Append('('); + Visit(node.Left); + _shape.Append(','); + Visit(node.Right); + _shape.Append(')'); + return node; + } + + protected override Expression VisitUnary(UnaryExpression node) + { + _shape.Append(node.NodeType).Append('('); + Visit(node.Operand); + _shape.Append(')'); + return node; + } + + protected override Expression VisitMember(MemberExpression node) + { + if (node.IsCompilableExpression()) + { + RecordSlot(node); + return node; + } + + _shape.Append("Mem(").Append(node.Member.DeclaringType?.FullName).Append('.').Append(node.Member.Name) + .Append(':'); + Visit(node.Expression); + _shape.Append(')'); + return node; + } + + protected override Expression VisitConstant(ConstantExpression node) + { + if (node.Value is IQueryable) + { + // The root document collection anchor -- structurally stable across every + // call with this shape, not a value slot. + _shape.Append("Root(").Append(node.Type.FullName).Append(')'); + return node; + } + + RecordSlot(node); + return node; + } + + protected override Expression VisitParameter(ParameterExpression node) + { + _shape.Append("Param(").Append(node.Type.FullName).Append(')'); + return node; + } + + protected override Expression VisitNew(NewExpression node) + { + _shape.Append("New(").Append(node.Type.FullName).Append(':'); + foreach (var argument in node.Arguments) + { + Visit(argument); + _shape.Append(','); + } + + _shape.Append(')'); + return node; + } + + private void RecordSlot(Expression node) + { + _shape.Append("Slot").Append(_slots.Count).Append(':').Append(node.Type.FullName); + _slots.Add(node); + } +} + diff --git a/src/Marten/Linq/Caching/LinqPlanRecorder.cs b/src/Marten/Linq/Caching/LinqPlanRecorder.cs new file mode 100644 index 0000000000..00e379e56b --- /dev/null +++ b/src/Marten/Linq/Caching/LinqPlanRecorder.cs @@ -0,0 +1,196 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using Marten.Internal; +using Marten.Internal.CompiledQueries; +using Marten.Internal.Sessions; +using Marten.Linq.Parsing; +using Marten.Linq.QueryHandlers; +using Marten.Util; +using Npgsql; + +namespace Marten.Linq.Caching; + +/// +/// Builds a for a query shape on a cache miss. +/// +/// +/// The approach mirrors how Marten already compiles ICompiledQuery plans +/// (): a "template" expression is built by replacing +/// every value slot with a distinct, never-otherwise-occurring sentinel value (reusing +/// the same / +/// machinery). That template is compiled through the normal LINQ pipeline exactly once, +/// and the resulting command's parameters are matched back to slots purely by sentinel +/// value identity -- no reflection into Weasel's (immutable) CommandParameter or +/// Marten's internal filter types is required. +/// +/// If any command parameter can't be confidently attributed to a slot (for example +/// a conjoined multi-tenancy tenant id, which is a per-session value rather than a +/// value captured by the query expression), the whole shape is rejected -- it is +/// never cached. This guarantees a cache hit can never replay a stale or foreign +/// parameter value. +/// +/// +internal static class LinqPlanRecorder +{ + public static CachedLinqPlan? TryBuild( + MartenLinqQueryProvider provider, + QuerySession session, + Expression realExpression, + ExpressionShapeVisitor shape, + Func> buildHandler) + { + if (!shape.IsSupported || shape.Slots.Count == 0) + { + return null; + } + + var sentinelValues = new object?[shape.Slots.Count]; + var valueSource = new UniqueValueSource(); + + for (var i = 0; i < shape.Slots.Count; i++) + { + var slotType = shape.Slots[i].Type; + var clrType = Nullable.GetUnderlyingType(slotType) ?? slotType; + + if (!QueryCompiler.Finders.Any(f => f.Matches(clrType))) + { + // No known way to manufacture a unique sentinel value for this CLR type + // (e.g. bool) -- don't cache. + return null; + } + + try + { + sentinelValues[i] = valueSource.GetValue(clrType); + } + catch (Exception) + { + return null; + } + } + + Expression templateExpression; + try + { + templateExpression = new SlotReplacingVisitor(shape.Slots, sentinelValues).Visit(realExpression)!; + } + catch (Exception) + { + return null; + } + + LinqQueryParser parser; + IQueryHandler handler; + Type[] documentTypes; + NpgsqlBatch templateBatch; + + try + { + parser = new LinqQueryParser(provider, session, templateExpression); + handler = buildHandler(parser); + documentTypes = parser.DocumentTypes().ToArray(); + templateBatch = ((IMartenSession)session).BuildCommand(handler); + } + catch (Exception) + { + return null; + } + + var bindings = new List(); + var usedSlots = new bool[sentinelValues.Length]; + + for (var ci = 0; ci < templateBatch.BatchCommands.Count; ci++) + { + var command = templateBatch.BatchCommands[ci]; + for (var pi = 0; pi < command.Parameters.Count; pi++) + { + var value = command.Parameters[pi].Value; + var matchedSlot = -1; + + for (var si = 0; si < sentinelValues.Length; si++) + { + if (usedSlots[si]) + { + continue; + } + + if (Equals(value, sentinelValues[si])) + { + matchedSlot = si; + break; + } + } + + if (matchedSlot < 0) + { + // A parameter we can't attribute to one of our captured slots (tenant + // id, other session-scoped values, etc.) -- refuse to cache rather than + // risk replaying a stale or foreign value on a future hit. + return null; + } + + usedSlots[matchedSlot] = true; + bindings.Add(new SlotBinding(ci, pi, matchedSlot)); + } + } + + return new CachedLinqPlan + { + Handler = handler, + TemplateBatch = templateBatch, + Bindings = bindings, + DocumentTypes = documentTypes + }; + } + + /// + /// Builds a fresh from a cached plan's template, replacing + /// each parameter's value with the current slot value supplied by the caller. This + /// is the whole point of the cache: no LINQ parsing, no SQL generation -- just a + /// cheap clone-and-rebind. + /// + public static NpgsqlBatch RebindValues(CachedLinqPlan plan, IReadOnlyList currentValues) + { + var batch = new NpgsqlBatch(); + + for (var ci = 0; ci < plan.TemplateBatch.BatchCommands.Count; ci++) + { + var source = plan.TemplateBatch.BatchCommands[ci]; + var command = new NpgsqlBatchCommand(source.CommandText); + + for (var pi = 0; pi < source.Parameters.Count; pi++) + { + var binding = findBinding(plan.Bindings, ci, pi); + var sourceParameter = source.Parameters[pi]; + + command.Parameters.Add(new NpgsqlParameter + { + ParameterName = sourceParameter.ParameterName, + Value = currentValues[binding.SlotIndex] ?? DBNull.Value + }); + } + + batch.BatchCommands.Add(command); + } + + return batch; + } + + private static SlotBinding findBinding(IReadOnlyList bindings, int commandIndex, int parameterIndex) + { + foreach (var binding in bindings) + { + if (binding.CommandIndex == commandIndex && binding.ParameterIndex == parameterIndex) + { + return binding; + } + } + + throw new InvalidOperationException( + "No slot binding found for a cached plan's parameter -- this is a bug in the query plan cache."); + } +} + diff --git a/src/Marten/Linq/Caching/SlotReplacingVisitor.cs b/src/Marten/Linq/Caching/SlotReplacingVisitor.cs new file mode 100644 index 0000000000..b04cd519e0 --- /dev/null +++ b/src/Marten/Linq/Caching/SlotReplacingVisitor.cs @@ -0,0 +1,37 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Runtime.CompilerServices; + +namespace Marten.Linq.Caching; + +/// +/// Replaces each of the given "slot" sub-expressions (matched by reference) with a +/// holding a supplied replacement value. Used to +/// build the sentinel-valued template expression that the plan cache compiles once +/// per shape (see ). +/// +internal sealed class SlotReplacingVisitor: ExpressionVisitor +{ + private readonly Dictionary _replacements; + + public SlotReplacingVisitor(IReadOnlyList slots, IReadOnlyList replacementValues) + { + _replacements = new Dictionary(ReferenceEqualityComparer.Instance); + for (var i = 0; i < slots.Count; i++) + { + _replacements[slots[i]] = replacementValues[i]; + } + } + + public override Expression? Visit(Expression? node) + { + if (node != null && _replacements.TryGetValue(node, out var value)) + { + return Expression.Constant(value, node.Type); + } + + return base.Visit(node); + } +} + diff --git a/src/Marten/Linq/MartenLinqQueryProvider.cs b/src/Marten/Linq/MartenLinqQueryProvider.cs index 2e3569ee8d..41116f04fe 100644 --- a/src/Marten/Linq/MartenLinqQueryProvider.cs +++ b/src/Marten/Linq/MartenLinqQueryProvider.cs @@ -9,9 +9,11 @@ using System.Threading.Tasks; using Marten.Events; using Marten.Exceptions; +using Marten.Internal; using Marten.Internal.Sessions; using Marten.Linq.Parsing; using Marten.Linq.QueryHandlers; +using Marten.Linq.Caching; using Marten.Linq.Selectors; using Marten.Util; @@ -58,7 +60,13 @@ public TResult Execute(Expression expression) internal async ValueTask EnsureStorageExistsAsync(LinqQueryParser parser, CancellationToken cancellationToken) { - foreach (var documentType in parser.DocumentTypes()) + await EnsureStorageExistsForTypesAsync(parser.DocumentTypes(), cancellationToken).ConfigureAwait(false); + } + + internal async ValueTask EnsureStorageExistsForTypesAsync(IEnumerable documentTypes, + CancellationToken cancellationToken) + { + foreach (var documentType in documentTypes) { await _session.Database.EnsureStorageExistsAsync(documentType, cancellationToken).ConfigureAwait(false); } @@ -79,6 +87,102 @@ internal async ValueTask EnsureStorageExistsAsync(LinqQueryParser parser, } } + /// + /// Entry point for the opt-in LINQ query plan cache (see + /// , https://github.com/JasperFx/marten/issues/5013). + /// Attempts to reuse a compiled plan for this expression's structural shape; falls + /// back to the normal, uncached execution path whenever the cache is disabled, the + /// shape isn't supported, or anything unexpected happens while trying to reuse a + /// cached plan. + /// + internal async Task> ExecuteListWithPlanCacheAsync(Expression expression, + CancellationToken token) + { + var cache = _session.Options.Linq.QueryPlanCache; + if (!cache.Enabled) + { + return await ExecuteListAsync(expression, token).ConfigureAwait(false); + } + + var shape = ExpressionShapeVisitor.Analyze(expression); + if (!shape.IsSupported) + { + return await ExecuteListAsync(expression, token).ConfigureAwait(false); + } + + var key = shape.BuildKey(SourceType, typeof(T)); + + if (cache.TryGet(key, out var cachedPlan)) + { + var result = await TryExecuteCachedPlanAsync(cachedPlan, shape, token).ConfigureAwait(false); + if (result != null) + { + return result; + } + + // Something about replaying the cached plan didn't work out (should be rare -- + // e.g. a transient failure). Fall through to the always-correct normal path + // rather than fail the caller's query. + } + else + { + var plan = LinqPlanRecorder.TryBuild>(this, _session, expression, shape, + p => p.BuildListHandler()); + if (plan != null) + { + cache.Set(key, plan); + } + } + + return await ExecuteListAsync(expression, token).ConfigureAwait(false); + } + + private async Task> ExecuteListAsync(Expression expression, CancellationToken token) + { + try + { + var parser = new LinqQueryParser(this, _session, expression); + var handler = parser.BuildListHandler(); + + await EnsureStorageExistsAsync(parser, token).ConfigureAwait(false); + + var result = await ExecuteHandlerAsync(handler, token).ConfigureAwait(false); + return result ?? Array.Empty(); + } + catch (Exception e) + { + MartenExceptionTransformer.WrapAndThrow(e); + throw; + } + } + + private async Task?> TryExecuteCachedPlanAsync(CachedLinqPlan plan, + ExpressionShapeVisitor shape, CancellationToken token) + { + try + { + var currentValues = new object?[shape.Slots.Count]; + for (var i = 0; i < shape.Slots.Count; i++) + { + currentValues[i] = shape.Slots[i].ReduceToConstant().Value; + } + + await EnsureStorageExistsForTypesAsync(plan.DocumentTypes, token).ConfigureAwait(false); + + var batch = LinqPlanRecorder.RebindValues(plan, currentValues); + + await using var reader = await _session.ExecuteReaderAsync(batch, token).ConfigureAwait(false); + var handler = (IQueryHandler>)plan.Handler; + return await handler.HandleAsync(reader, _session, token).ConfigureAwait(false); + } + catch (Exception) + { + // Never let a cache-replay failure surface to the caller as an error -- fall + // back to the normal, always-correct execution path instead. + return null; + } + } + public async Task ExecuteAsync(Expression expression, CancellationToken token, SingleValueMode valueMode) where TResult : notnull @@ -199,3 +303,4 @@ public async Task StreamOne(Expression expression, Stream destination, Can return await _session.StreamOne(command, destination, token).ConfigureAwait(false); } } + diff --git a/src/Marten/Linq/MartenLinqQueryable.cs b/src/Marten/Linq/MartenLinqQueryable.cs index b05bc469f9..0f1d0c2953 100644 --- a/src/Marten/Linq/MartenLinqQueryable.cs +++ b/src/Marten/Linq/MartenLinqQueryable.cs @@ -161,6 +161,21 @@ public async Task> ToListAsync(CancellationToken return await MartenProvider.ExecuteHandlerAsync(handler, token).ConfigureAwait(false); } + /// + /// Same as , but allows opting + /// this specific query into the store's via + /// . See https://github.com/JasperFx/marten/issues/5013. + /// + public Task> ToListAsync(CancellationToken token, QueryPlanCaching caching) + { + if (caching == QueryPlanCaching.Cached) + { + return MartenProvider.ExecuteListWithPlanCacheAsync(Expression, token); + } + + return ToListAsync(token); + } + public IAsyncEnumerable ToAsyncEnumerable(CancellationToken token = default) { return MartenProvider.ExecuteAsyncEnumerable(Expression, MartenProvider, token); diff --git a/src/Marten/Linq/QueryPlanCache.cs b/src/Marten/Linq/QueryPlanCache.cs new file mode 100644 index 0000000000..d1e5a0a107 --- /dev/null +++ b/src/Marten/Linq/QueryPlanCache.cs @@ -0,0 +1,103 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using Marten.Linq.Caching; + +namespace Marten.Linq; + +/// +/// Opt-in, bounded cache that reuses compiled LINQ query plans across calls that share +/// the same structural "shape" (the same Where/OrderBy/ThenBy/Skip/Take/Select chain) +/// but differ only in the captured filter values -- e.g. endpoints with optional / +/// conditional Where() clauses that can't otherwise use ICompiledQuery +/// because every filter combination produces a different shape. +/// +/// +/// See https://github.com/JasperFx/marten/issues/5013. Disabled by default; enable with +/// storeOptions.Linq.QueryPlanCache = QueryPlanCache.PerShape(); or +/// storeOptions.Linq.EnableQueryPlanCaching();, then opt individual queries in +/// with query.ToListAsync(token, QueryPlanCaching.Cached). +/// +public sealed class QueryPlanCache +{ + private readonly ConcurrentDictionary _entries = new(); + private readonly ConcurrentQueue _insertionOrder = new(); + + private QueryPlanCache(int maxEntries, bool enabled) + { + MaxEntries = maxEntries; + Enabled = enabled; + } + + /// + /// The maximum number of distinct shapes that will be cached. Once exceeded, the + /// oldest cached shapes are evicted first. + /// + public int MaxEntries { get; } + + /// + /// Whether this cache is active. The default instance always + /// returns false here. + /// + public bool Enabled { get; } + + /// + /// The number of shapes currently cached. + /// + public int Count => _entries.Count; + + /// + /// The default, disabled cache. Query plan caching is opt-in. + /// + public static QueryPlanCache Disabled { get; } = new(0, false); + + /// + /// Creates an enabled, bounded per-shape query plan cache. + /// + /// The maximum number of distinct query shapes to cache. + public static QueryPlanCache PerShape(int maxEntries = 1024) + { + if (maxEntries < 1) + { + throw new ArgumentOutOfRangeException(nameof(maxEntries), "Must be greater than zero"); + } + + return new QueryPlanCache(maxEntries, true); + } + + internal bool TryGet(string key, out CachedLinqPlan plan) + { + return _entries.TryGetValue(key, out plan!); + } + + internal void Set(string key, CachedLinqPlan plan) + { + if (!_entries.TryAdd(key, plan)) + { + return; + } + + _insertionOrder.Enqueue(key); + trimIfNecessary(); + } + + private void trimIfNecessary() + { + while (_entries.Count > MaxEntries && _insertionOrder.TryDequeue(out var oldest)) + { + _entries.TryRemove(oldest, out _); + } + } + + /// + /// Removes every cached plan. Mostly useful for tests. + /// + public void Clear() + { + _entries.Clear(); + while (_insertionOrder.TryDequeue(out _)) + { + } + } +} + diff --git a/src/Marten/Linq/QueryPlanCaching.cs b/src/Marten/Linq/QueryPlanCaching.cs new file mode 100644 index 0000000000..6fdbba37bc --- /dev/null +++ b/src/Marten/Linq/QueryPlanCaching.cs @@ -0,0 +1,23 @@ +namespace Marten.Linq; + +/// +/// Per-query opt-in for the . Named distinctly from +/// (the Postgres EXPLAIN model) to avoid confusion between the +/// two unrelated concepts. +/// +public enum QueryPlanCaching +{ + /// + /// Parse and compile this query normally. The default. + /// + Default = 0, + + /// + /// Attempt to reuse a cached compiled plan for this query's structural shape via + /// StoreOptions.Linq.QueryPlanCache. Has no effect unless the store has an + /// enabled (see ), + /// and only applies to shapes the cache knows how to safely replay -- anything else + /// silently falls back to the normal, uncached execution path. + /// + Cached = 1 +} diff --git a/src/Marten/LinqParsing.cs b/src/Marten/LinqParsing.cs index 9df25b0fe9..9f2bcbef6f 100644 --- a/src/Marten/LinqParsing.cs +++ b/src/Marten/LinqParsing.cs @@ -7,6 +7,7 @@ using JasperFx.Core; using Marten.Events; using Marten.Events.Archiving; +using Marten.Linq; using Marten.Linq.CreatedAt; using Marten.Linq.LastModified; using Marten.Linq.MatchesSql; @@ -123,6 +124,23 @@ internal LinqParsing(StoreOptions options) _options = options; } + /// + /// Opt-in cache that reuses compiled LINQ query plans across calls sharing the same + /// structural shape (see https://github.com/JasperFx/marten/issues/5013). Disabled + /// by default; enable with or by assigning + /// directly. + /// + public QueryPlanCache QueryPlanCache { get; set; } = QueryPlanCache.Disabled; + + /// + /// Enables the opt-in, bounded, per-shape LINQ query plan cache. + /// + /// The maximum number of distinct query shapes to cache. + public void EnableQueryPlanCaching(int maxEntries = 1024) + { + QueryPlanCache = QueryPlanCache.PerShape(maxEntries); + } + /// /// Register extensions to the Marten Linq support for special handling of /// specific .Net types diff --git a/src/Marten/QueryableExtensions.cs b/src/Marten/QueryableExtensions.cs index b3cb565fce..ef84fdcf18 100644 --- a/src/Marten/QueryableExtensions.cs +++ b/src/Marten/QueryableExtensions.cs @@ -57,6 +57,23 @@ public static Task> ToListAsync(this IQueryable queryable return queryable.As>().ToListAsync(token); } + /// + /// Fetch results asynchronously to a read only list, opting this specific query into + /// the store's opt-in when + /// is . See + /// https://github.com/JasperFx/marten/issues/5013. + /// + /// + /// + /// + /// + /// + public static Task> ToListAsync(this IQueryable queryable, + CancellationToken token, QueryPlanCaching caching) where T : notnull + { + return queryable.As>().ToListAsync(token, caching); + } + #endregion ToList /// From b6b1a3a25489bfe37894d011a46e20457f9f3b57 Mon Sep 17 00:00:00 2001 From: Anne Erdtsieck Date: Wed, 22 Jul 2026 11:31:55 +0200 Subject: [PATCH 2/2] docs: document the opt-in query plan cache (#5013) 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 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. --- docs/documents/querying/compiled-queries.md | 86 +++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/docs/documents/querying/compiled-queries.md b/docs/documents/querying/compiled-queries.md index c4e446b819..1b5770ff36 100644 --- a/docs/documents/querying/compiled-queries.md +++ b/docs/documents/querying/compiled-queries.md @@ -778,3 +778,89 @@ public async Task use_as_batch() ``` snippet source | anchor + +## Query Plan Cache for Ad Hoc Linq Queries + +::: info +Not to be confused with the `IQueryPlan` "Specification" pattern described above, or with the Postgres `EXPLAIN` +output returned by [`ExplainAsync()`](/diagnostics#previewing-the-postgresql-query-plan). This is a distinct, opt-in +performance feature for ordinary `IQueryable` Linq queries. +::: + +`ICompiledQuery` gives you the best raw performance by completely bypassing Linq expression parsing, but it comes at +the cost of a fixed query shape -- every compiled query class represents exactly one filter/sort/paging combination. +That is a poor fit for endpoints with _optional_ or _conditional_ filters, where the same handler builds up a +different `Where()` chain depending on which query string parameters were supplied. Historically, every distinct +combination of filters on that kind of endpoint paid the full cost of Linq expression parsing and SQL generation on +every single call, because each combination is technically a different Linq expression tree. + +The query plan cache closes that gap. It is an opt-in, bounded cache that recognizes when two Linq queries share the +same _structural shape_ -- the same sequence of `Where()`, `OrderBy()`/`OrderByDescending()`, `ThenBy()`/ +`ThenByDescending()`, `Skip()`, `Take()`, and `Select()` calls -- even though the actual filter values differ. On a +cache hit, Marten skips Linq parsing and SQL generation entirely and just substitutes the new filter values into the +previously-built database command. + +### Enabling the cache + +The cache is disabled by default. Turn it on for the whole `DocumentStore` with either of these equivalent options: + +```cs +var opts = new StoreOptions(); + +// Full control over the maximum number of cached shapes +opts.Linq.QueryPlanCache = QueryPlanCache.PerShape(maxEntries: 1024); + +// Or the shorthand version, also with a default of 1024 entries +opts.Linq.EnableQueryPlanCaching(); +``` + +Once enabled, opt individual Linq queries into the cache by passing `QueryPlanCaching.Cached` to `ToListAsync()`: + +```cs +IReadOnlyList lines = await session + .Query() + .Where(x => x.CustomerId == customerId) + .Where(x => status.HasValue ? x.Status == status.Value : true) + .OrderBy(x => x.CreatedAt) + .ToListAsync(cancellation, QueryPlanCaching.Cached); +``` + +Every call to that code with a different `customerId` and/or `status` reuses the same cached plan the second and +subsequent times it's called, as long as which `Where()` clauses are present (not their values) stays the same. + +### How shape keys work + +Behind the scenes, Marten walks the Linq expression tree for the query and builds a structural "shape key": + +* Method calls, operators, and member names contribute to the key by their _name_ and _position_ in the tree, never + by value. +* Anything that looks like a captured local variable, method parameter, or literal constant -- i.e., the actual + filter values -- is treated as a positional "slot" rather than part of the shape. Two queries with the identical + Linq structure but different slot values always produce the same shape key. +* The key is scoped by both the source document type and the requested result type, so two structurally identical + queries against different document types never collide. + +Only a deliberately narrow, safe subset of Linq is recognized as cacheable: `Where`, `OrderBy`, `OrderByDescending`, +`ThenBy`, `ThenByDescending`, `Skip`, `Take`, and `Select`, built from simple comparisons and member access. Anything +outside that -- `Include()`, `Stats()`, raw/custom SQL, `StartsWith()`/`Contains()`, `GroupBy()`, `SelectMany()`, and +so on -- is automatically recognized as unsupported for caching. Unsupported queries are not an error; they simply +execute through Marten's normal, always-correct Linq pipeline exactly like they do today, with no caching applied. + +::: tip +Correctness always wins over caching. If Marten can't fully account for every parameter that will end up in the +generated SQL command for a shape -- for example, a conjoined multi-tenancy `tenant_id` parameter, which comes from +the session rather than from the query expression -- that shape is simply never cached rather than risk ever +replaying a stale or unrelated value. +::: + +### Bounded cache size + +`QueryPlanCache.PerShape(maxEntries)` bounds the number of distinct shapes that will be cached at once. Once that +limit is reached, the oldest cached shapes are evicted first to make room for new ones, so the cache can't grow +without bound even for endpoints that produce a very large number of distinct filter combinations. + +### Current scope + +As of this writing, the query plan cache only applies to the `ToListAsync(CancellationToken, +QueryPlanCaching)` overload. Other terminal operators such as `CountAsync()`, `AnyAsync()`, and streaming queries do +not yet participate in the cache.