Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions docs/documents/querying/compiled-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -778,3 +778,89 @@ public async Task use_as_batch()
```
<sup><a href='https://github.com/JasperFx/marten/blob/master/src/DocumentDbTests/Reading/query_plans.cs#L34-L71' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_using_query_plan_in_batch_query' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Query Plan Cache for Ad Hoc Linq Queries

::: info
Not to be confused with the `IQueryPlan<T>` "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<T>` 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<Invoice> lines = await session
.Query<Invoice>()
.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<TResult>(CancellationToken,
QueryPlanCaching)` overload. Other terminal operators such as `CountAsync()`, `AnyAsync()`, and streaming queries do
not yet participate in the cache.
155 changes: 155 additions & 0 deletions src/LinqTests/query_plan_cache_Tests.cs
Original file line number Diff line number Diff line change
@@ -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<Target> 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<Target>().Where(x => x.Number == 2).ToListAsync();

var cached = await session.Query<Target>().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<Target>().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<Target>().Where(x => x.Number == 1)
.ToListAsync(default, QueryPlanCaching.Cached);
await session.Query<Target>().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<Target>().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<Target> 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<Target> 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<Target> queryable = new Target[0].AsQueryable();
var expr = queryable.Skip(skip).Take(take).Expression;
return (expr, skip);
}
}
31 changes: 31 additions & 0 deletions src/Marten/Linq/Caching/CachedLinqPlan.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#nullable enable
using System;
using System.Collections.Generic;
using Marten.Linq.QueryHandlers;
using Npgsql;

namespace Marten.Linq.Caching;

/// <summary>
/// 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 <see cref="NpgsqlBatch" /> with
/// unique sentinel parameter values recorded once, and the mapping from each parameter
/// back to the slot (position in <see cref="ExpressionShapeVisitor.Slots" />) that
/// supplied it.
/// </summary>
internal sealed class CachedLinqPlan
{
public required IQueryHandler Handler { get; init; }
public required NpgsqlBatch TemplateBatch { get; init; }
public required IReadOnlyList<SlotBinding> Bindings { get; init; }
public required IReadOnlyList<Type> DocumentTypes { get; init; }
}

/// <summary>
/// Records that the parameter at <see cref="ParameterIndex" /> within the batch command
/// at <see cref="CommandIndex" /> should be rebound, on every cache hit, from the
/// current value of the expression slot at <see cref="SlotIndex" />.
/// </summary>
internal readonly record struct SlotBinding(int CommandIndex, int ParameterIndex, int SlotIndex);

Loading
Loading