Map, Clone, Batch
Goal: get results into the shape the caller needs with the fewest copies, and never hand out a cache instance that someone will mutate.
Prerequisite: an OrderCache. The code is in MapCloneBatch.cs; every block runs as a test.
1. Project with Map
Map returns a result of the same pooled kind holding the projected items and disposes the source for you; TotalCount is carried over.
using var totals = orders.Query()
.WithCustomerId(42)
.ExecutePooled(skip: 0, take: 10)
.Map(o => new OrderTotal(o.OrderId, o.Total)); // same pooled kind; the Order buffer is disposed for you
Console.WriteLine($"{totals.Count} of {totals.TotalCount}"); // TotalCount survives the projection
The using on the mapped result is the one that matters; disposing the source afterwards is a safe no-op.
2. Conversions that also consume
ToArray, ToList, ToHashSet and ToDictionary copy out and return the buffer in one step. On a non-pooled Execute() whose buffer is exactly full, ToArray hands back the buffer itself. The implicit ReadOnlySpan<T> conversion is a view, valid until Dispose.
var list = orders.Query().WithCustomerId(42).ExecutePooled().ToList(); // copies out, returns the buffer
var byId = orders.Query().WithCustomerId(42).ExecutePooled().ToDictionary(o => o.OrderId);
var array = orders.Query().WithCustomerId(42).Execute().ToArray(); // non-pooled, whole buffer: no copy
using var rows = orders.Query().WithCustomerId(42).ExecutePooled();
ReadOnlySpan<Order> span = rows; // no copy; valid until Dispose
3. Clone a row
Rows are the cache's own instances. If you need to mutate one (patch a DTO in place, stage an update), take a copy. The generated Clone() is deep, collection-aware and reflection-free.
var copy = resident!.Clone(); // generated: recursive, collection-aware, no reflection
var sameContent = copy.CacheEquals(resident); // true
var sameInstance = ReferenceEquals(copy, resident); // false: mutate the copy, the cache never sees it
The *Cloned terminals clone every row on the way out; CloneElements() does it in place on a result you already hold.
using var shared = orders.Query().WithOrderId("ORD-001").ExecutePooled(); // rows ARE the cache's instances
using var copies = orders.Query().WithOrderId("ORD-001").ExecutePooledCloned(); // every row cloned on the way out
using var later = orders.Query().WithOrderId("ORD-001").ExecutePooled();
later.CloneElements(); // or clone in place, after the fact
4. Read several keys
The cache exposes batch lookups that skip the query builder entirely.
string[] keys = ["ORD-001", "ORD-003", "ORD-999"];
Span<Order> buffer = new Order[keys.Length];
var found = orders.Cache.TryGet(keys, buffer); // fills the span with the hits, returns how many
var hits = orders.Cache.TryGet(keys); // List<Order> of the found items only
var big = orders.Cache.TryGet(keys, o => o.Total > 100m); // ... that also pass the predicate
if (orders.Cache.TryGet("ORD-002", out var one)) // single key: no allocation
Console.WriteLine(one.Total);
When the batch needs lanes or filters as well, the primary key is a lane like any other, with several values meaning OR within the lane.
string[] keys = ["ORD-001", "ORD-003"];
using var rows = orders.Query()
.WithKey(keys) // multi-value primary-key lane (OR within the lane)
.Where(o => o.Status == OrderStatus.Paid)
.ExecutePooled();
What allocates
| Call | Allocation |
|---|---|
ExecutePooled() + using |
none (pooled buffer) |
.Map(f) on pooled |
none beyond the projected items |
ToList() / ToArray() on pooled |
one collection |
ExecutePooledCloned(), Clone() |
one object graph per row |
Pitfalls
- Mutating a row from
ExecutePooled(). Every other reader sees it, and the cache's equality check on the next message compares against your mutation. Clone first. - Keeping the
ReadOnlySpan<T>past theusing. The buffer is back in the pool and may be rented by another query. - Cloning by default. It costs an allocation per row; most read paths copy into a DTO anyway, which is one allocation and a narrower shape.