diff --git a/docs/documents/querying/compiled-queries.md b/docs/documents/querying/compiled-queries.md index c4e446b819..84c6d59e68 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. 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 ///