Prepared and Frozen Queries
The eager builder (Query()…Execute()) plans and executes a query in one breath. A prepared query
separates the two: describe the query once, optionally with a parameter struct, store it in a field, and
execute it any number of times from any thread. A frozen query goes one step further and plans the
execution once too, binding the description to an executor built for that exact shape. Every block on
this page is compiled and asserted in samples/Prague.Samples/Concepts/PreparedQueries.cs.
public sealed class CustomerOrdersPage {
private readonly FrozenQuery<(int CustomerId, OrderStatus Status), JoinResult<Order, Customer?>> _page;
public CustomerOrdersPage(OrderCache orders) {
_page = orders.Prepare<(int CustomerId, OrderStatus Status)>()
.WithCustomerId(static a => a.CustomerId)
.WithStatus(static a => a.Status)
.SortBounded(OrderCache.ByCreatedAtComparer)
.JoinWithCustomer()
.BuildFrozen(); // the only allocation
}
public int CountPage(int customerId, OrderStatus status) {
using var page = _page.ExecutePooled((customerId, status), skip: 0, take: 20); // 0 B, any thread
return page.Count;
}
}
Prepare() and Prepare<TArgs>() exist on every generated cache and on the raw InMemoryDataCache
(UseIndex(index, static a => …) instead of WithXxx). Every WithXxx / WithoutXxx / WithKey /
UpdatedAfter overload has a prepared twin taking a Func<TArgs, T>. Write the selectors as static
lambdas so the delegate is created once, at build.
Two terminals
Build() |
BuildFrozen() |
|
|---|---|---|
| Returns | PreparedQuery<TArgs, TResult> |
FrozenQuery<TArgs, TResult> |
| Per execution | replays the description into a fresh eager core | runs a pre-bound executor |
| Rows, counts, paging, clone timing, pooling | identical to eager | identical to eager |
| Row order | identical to eager | not guaranteed unless sorted (see below) |
| Allocation per pooled execution | 0 B | 0 B |
| When to use | you need the eager sequence byte for byte | everything else |
var replayed = _orders.Prepare<int>().WithCustomerId(static a => a).Build(); // PreparedQuery: replays the description
var frozen = _orders.Prepare<int>().WithCustomerId(static a => a).BuildFrozen(); // FrozenQuery: pre-bound executor
using var fromReplay = replayed.ExecutePooled(42);
using var fromFrozen = frozen.ExecutePooled(42);
The arguments struct
TArgs must be a struct — a tuple, a readonly record struct, a primitive such as int, or NoArgs
for the parameterless Prepare(). A class — Prepare<string>() included — is a compile error: the whole
prepared path is constrained where TArgs : struct, which keeps every generic over it JIT-specialized.
The examples below share one:
public enum LookupMode {
ByCustomer,
ByStatus,
}
public readonly record struct OrderLookup(LookupMode Mode, int CustomerId, OrderStatus Status, decimal MinTotal, bool SettledOnly);
Prepared builders have no Execute*; Build() / BuildFrozen() are the only terminals. On the built
query, Execute, ExecutePooled, Count and the *Cloned variants take in TArgs plus the usual skip /
take. Every selector callback receives TArgs by value and runs once per execution, so no spelling
changes there. One callback is different: the arg Where predicate. It runs once per candidate row,
so it takes TArgs by in through the ArgFilter<TValue, TArgs> delegate, and the lambda must spell
the modifier — static (o, a) => … without the in does not compile (CS1676):
var expensive = _orders.Prepare<(int CustomerId, decimal MinTotal)>()
.WithCustomerId(static a => a.CustomerId)
.Where(static (o, in a) => o.Total >= a.MinTotal) // the arg predicate runs per row: TArgs by in, spelled out
.BuildFrozen();
Prefer a readonly struct or readonly record struct for TArgs: in removes the copy at the call,
but reading a property of a non-readonly struct through an in reference copies it again inside the
lambda. Field access on a ValueTuple is free.
Branching inside a query
Prepared queries record their structure rather than running it, so a query can branch on its arguments and the planner still sees every arm.
Or — the union of two narrowing branches, exactly as in the eager builder.
Match, in two forms. Every Match must end in a Default: the arm chain is type-state, so an
open chain, an empty one, or a Case after Default is a compile error, and exactly one arm always runs.
// Tag form — a switch: one selector call per execution, then constant compares; first equal tag wins.
var byMode = _orders.Prepare<OrderLookup>()
.Match(static a => a.Mode, m => m
.Case(LookupMode.ByCustomer, b => b.WithCustomerId(static a => a.CustomerId))
.Case(LookupMode.ByStatus, b => b.WithStatus(static a => a.Status))
.Default(b => b.Where(static o => o.Total >= 100m)))
.BuildFrozen();
// Guard form — if / else if / else: no selector; the guards run in order until one is true.
var guarded = _orders.Prepare<OrderLookup>()
.WithCustomerId(static a => a.CustomerId)
.Match(m => m
.Case(static a => a.MinTotal > 0, b => b.Where(static (o, in a) => o.Total >= a.MinTotal))
.Case(static a => a.SettledOnly, b => b.WithSettledAtUnixMs())
.Default()) // "nothing matched, narrow nothing", stated
.BuildFrozen();
If(cond, branch) / IfElse(cond, then, otherwise) are sugar over a one-Case guard Match:
var settledOnly = _orders.Prepare<OrderLookup>()
.WithCustomerId(static a => a.CustomerId)
.If(static a => a.SettledOnly, b => b.WithSettledAtUnixMs())
.BuildFrozen();
var either = _orders.Prepare<OrderLookup>()
.IfElse(static a => a.SettledOnly,
b => b.WithSettledAtUnixMs(),
b => b.WithStatus(static a => a.Status))
.BuildFrozen();
What a branch may contain: Or branches take WithXxx / UseIndex (bound or parameterized), nested
Or, and narrow-only If / Match. If branches and Match arms take all of that plus Where and
nested Or / If / Match. Joins, Sort / SortBounded and the terminal are top-level only.
What BuildFrozen() does
At build time the planner classifies the chain and binds one of three executors:
- Point lookup — a unique-index equality followed only by filters: one hash lookup per execution.
- Pipeline — everything else it can take. One index step's keys are copied out (the seed), every
other step becomes an O(1) probe on the key or on the fetched value, filters are fused into the pass,
Sort/SortBoundedfeed the top-k container directly, and fusable joins do one right lookup per row inside the same pass. No candidate set, no intersection. - Replay — the prepared behaviour, for shapes the pipeline does not take yet (nested
JoinMany, two sorters, a marking step that is not first in anOrbranch, filter-only plans, and a few more).
Explain() prints the plan and its live state:
var frozen = _orders.Prepare<(int CustomerId, OrderStatus Status)>()
.WithCustomerId(static a => a.CustomerId)
.WithStatus(static a => a.Status)
.BuildFrozen();
Console.WriteLine(frozen.Explain()); // executor, seed rule, steps, filters
The first line names the executor (executor: Pipeline, PointLookup or Replay). narrowers: lists
each step with its kind and binding — ListEq (arg), UniqueEq (arg), Filter (bound) — and the index
it reads. optimizations: lists the FrozenOptions in effect. A pipeline plan then prints its seed rule,
its steps with the side each probes on (steps: [0 ListEq probe: value-side, 1 ListEq probe: value-side]),
its filters, sort: bounded when a page feeds the top-k container, and joins: 1 (fused: 1, …). After
the first execution a last seed: line names the step that seeded and the signal that chose it —
last seed: step 1 ListEq (signal 34), free: smallest signal, or under PreserveEagerOrder
last seed: step 0 ListEq (signal 0), fixed: first active step. Read it once after building; if it says
Replay, the shape is not on the pipeline yet.
The ordering contract
Order is not part of the contract unless you ask for it. An unsorted frozen result carries no
row-order guarantee, and the ties of a sorted one are unspecified. A Sort / SortBounded with a
total comparer is byte-identical to eager. Everything else is unchanged: same rows, same Count /
TotalCount / Truncated, pages that are slices of the same whole, same clone timing and pooling.
That freedom is what pays. The pipeline seeds from whichever step is narrowest for this execution's
arguments, not the one declared first, so a thousand-row bucket followed by a hundred-row bucket walks
the hundred rows whichever way round you wrote it. If you need the eager sequence back, for a snapshot
diffed row by row or a golden test, set PreserveEagerOrder:
var eagerOrder = _orders.Prepare<(int CustomerId, OrderStatus Status)>()
.WithCustomerId(static a => a.CustomerId)
.WithStatus(static a => a.Status)
.BuildFrozen(new FrozenOptions { PreserveEagerOrder = true });
FrozenOptions
| Flag | Default | Effect |
|---|---|---|
Pipeline |
true |
allow the pipeline executor at all; false forces replay |
FuseFilters |
true |
run all Where predicates as one fused pass |
AdaptiveFilterOrdering |
true |
reorder fused predicates by their observed selectivity |
CapacityHints |
true |
size result containers from the previous execution |
IndexSideProbes |
false |
probe list steps on the index side instead of the fetched value |
PreserveEagerOrder |
false |
pin the seed to the first declared step and keep the eager sequence everywhere |
Measured
Raw cache, 100k rows, 1k-row list buckets, Apple M4 Pro, .NET 9, --inProcess, pooled and disposed;
every frozen row 0 B per execution (benchmarks/Prague.Benchmarks/FrozenQueryBenchmarks.cs, tables in
RESULTS.MD).
| Shape | Eager | Build() |
BuildFrozen() |
Ratio |
|---|---|---|---|---|
| unique lookup, parameterized | 132 ns | 129 ns | 18.0 ns | 7.3× |
list bucket + Where |
8.62 µs | 8.58 µs | 5.84 µs | 1.5× |
| list ∩ list (1k ∩ 100, largest declared first) | 9.26 µs | 9.11 µs | 1.29 µs | 7.2× |
| list ∩ list ∩ list (1k ∩ 333 ∩ 111) | 11.2 µs | 11.1 µs | 1.78 µs | 6.3× |
| list ∩ 60k-row range window | 398 µs | 396 µs | 11.2 µs | 35× |
| "everything since T" ∩ list | 216 µs | 216 µs | 9.66 µs | 22× |
Or of two 1k buckets, first narrowing |
976 µs | 983 µs | 26.9 µs | 36× |
SortBounded page of 20 over list ∩ list |
10.8 µs | 10.8 µs | 2.84 µs | 3.8× |
3 lists → SortBounded(page) → JoinOne |
14.2 µs | 13.9 µs | 3.9 µs | 3.6× |
…the same shape's Count |
11.0 µs | 10.8 µs | 1.42 µs | 7.7× |
Sort over a 1k bucket → JoinMany |
118 µs | 119 µs | 14.3 µs | 8.3× |
generated JoinWith{Ref} (forward many-to-one) |
51.4 µs | — | 25.5 µs | 2.0× |
generated reverse one-to-one after SortBounded(page) |
31.0 µs | — | 16.5 µs | 1.9× |
Rules of thumb
- Store the built query in a field.
Build()/BuildFrozen()is the only allocation; both results are immutable and thread-safe. - Use
BuildFrozen()unless you need the eager row sequence; then useBuild()orPreserveEagerOrder. - Make
TArgsareadonly record struct; spell argWherepredicatesstatic (v, in a) => …. - Every
Matchends inDefault(...)orDefault().
See also: Query Engine, Sorted Paging, Joins.