Skip to content
Improve this page

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 the using. 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.