Sorted Paging
Sorted queries come in two execution plans, and you choose one. Nothing is inferred from the
arguments: the right plan depends on how much of the matched result you are about to take, which
only you know. Both accept any IComparer<T>, including the XxxCache.{Name}Comparer fields
emitted from [DataCacheSort]. The blocks below run against a 120-order sample cache and are
asserted in samples/Prague.Samples/Concepts/SortedPaging.cs.
// Paging a large result: select just the page, never sort the rest.
using var page = _orders.Query()
.WithCustomerId(42)
.SortBounded(OrderCache.ByCreatedAtComparer)
.ExecutePooled(skip: 40, take: 20);
// Materialising a whole result: classic full sort.
using var all = _orders.Query()
.WithCustomerId(42)
.Sort(OrderCache.ByCreatedAtComparer)
.ExecutePooled();
Which one
| You are… | Use | Why |
|---|---|---|
paging — take is a small slice of what matched (tens to a few hundred rows) |
SortBounded |
2.3–8.6× faster; skip is irrelevant, so deep pages win too |
taking a large chunk — take covers roughly a quarter or more of what matched |
Sort |
SortBounded is 1.3–2.7× slower there: no sort left to skip, only overhead |
calling Execute*() with no page at all |
Sort |
unbounded always runs the classic sort anyway |
The deciding variable is take as a fraction of the matched rows — not skip, and not the cache
size.
Why the two differ
Sort materialises every matched row and sorts all of them, then slices the page out. Cost is
O(N log N) regardless of how few rows you asked for.
SortBounded never sorts what you did not ask for. With a finite take it picks a plan once, from
K = skip + take against the number of candidates:
Kbelow a quarter of the candidates — a pooled max-heap ofKrows.O(N log K), and the buffer is sized toK, not to the result.- otherwise — collect the candidates, then introselect the page boundaries and sort only the
page.
O(N + take log take).
It buffers rows as (value, ordinal) pairs — twice the bytes of a bare reference — and earns that
back by skipping the sort of everything outside the page. Ask for a page and the saving dwarfs the
overhead. Ask for most of the result and only the overhead is left, which is why a near-full take
is slower than Sort and is the one shape where the plan allocates (an O(N) collect buffer).
Chain shapes the bounded plan cannot handle — a comparer over joined fields, a sorter applied after a join, an inner join that cannot narrow — fall back to the classic pipeline automatically, with identical results.
Ties
A comparer that returns 0 for two distinct rows leaves nothing to chance here: both plans are
stable. Sort keeps comparer-equal rows in encounter order; SortBounded carries each row's
encounter ordinal and breaks ties by it. The two plans therefore return the same rows in the same
order for the same query, and consecutive pages of an unchanged result partition it with no
duplicates and no gaps — under either plan. The sample sorts by Status, which ties thirty ways:
public readonly struct ByStatus : IComparer<Order> { // ties: many orders share a status
public int Compare(Order? x, Order? y) => x!.Status.CompareTo(y!.Status);
}
using var classic = _orders.Query().WithCustomerId(42).Sort(new ByStatus()).ExecutePooled();
using var bounded = _orders.Query().WithCustomerId(42).SortBounded(new ByStatus()).ExecutePooled(skip: 0, take: OrderCount);
var seen = new List<string>();
for (var skip = 0; skip < OrderCount; skip += 25) {
using var page = _orders.Query().WithCustomerId(42).SortBounded(new ByStatus()).ExecutePooled(skip, take: 25);
seen.AddRange(Ids(page));
}
Encounter order is the order the candidate set yields rows, which for one cache and one query is repeatable but is not something you can predict or build on. If the order within a tie group matters to a reader, make the comparer total — fall back to the primary key when the sort key ties:
public readonly struct ByTotalDescThenOrderId : IComparer<Order> {
public int Compare(Order? x, Order? y) {
var byTotal = y!.Total.CompareTo(x!.Total);
return byTotal != 0 ? byTotal : string.CompareOrdinal(x.OrderId, y.OrderId); // total: no ties between distinct rows
}
}
A total order has exactly one sorted permutation, so the page is then defined by the data alone:
using var classic = _orders.Query().WithCustomerId(42).Sort(new ByTotalDescThenOrderId()).ExecutePooled(skip: 30, take: 25);
using var bounded = _orders.Query().WithCustomerId(42).SortBounded(new ByTotalDescThenOrderId()).ExecutePooled(skip: 30, take: 25);
Neither plan gives you snapshot isolation: pages partition an unchanged result. Writes landing between two page queries can still move a row across the boundary.
After Sort / SortBounded only the outer, unfiltered JoinWith{T}() binds. On a
frozen query the seed lane — and so the encounter order — is chosen per
execution, which leaves the order inside a tie group unspecified there; a total comparer gives
byte-identical pages on the frozen path too, and FrozenOptions.PreserveEagerOrder pins the
ordinals back to the eager ones.
Allocation
SortBounded on a page allocates nothing. The comparer reaches the selection code as a struct
type parameter, so there is no Comparison<T> delegate and no boxed IComparer<T>, and the heap
buffer is pooled. Sort is 0 B per query with a struct comparer as well: the comparer travels as a
type parameter through every frame and the sort runs over a pooled index array. Passing a reference
comparer — the generated {Name}Comparer fields are IComparer<T> — trades that for an interface
call per comparison, still with no per-row allocation.
Measured
Apple M4 Pro, .NET 9, both plans in the same process, BenchmarkDotNet ShortRun. Read as order-of-magnitude — error bars on a laptop are wide.
| shape | SortBounded |
Sort |
|
|---|---|---|---|
| page of 20, 100k rows | 940 µs | 8,066 µs | 8.6× faster |
| page of 200, 10k rows | 194 µs | 449 µs | 2.3× faster |
| page of 20 at the end, 100k rows | 2,192 µs | 7,553 µs | 3.4× faster |
| joined page of 20, 100k rows | 4,847 µs | 24,404 µs | 5.0× faster |
| half the result, 100k rows | 10,281 µs | 7,766 µs | 1.3× slower |
| the whole result, 100k rows | 16,241 µs | 7,555 µs | 2.1× slower |