Index Handle Resolution (IndexRef)
Resolve an index once on the cold path, then reuse the handle for free on every hot-path call.
Status: โ Implemented ยท Visibility: Public ยท Level: ๐ฃ Advanced ยท Category: Indexing
๐ฏ What it solves
Hot-path code (a per-tick query, a lookup inside a system loop) needs to repeatedly target the same primary-key or
secondary index without paying repeated resolution cost โ naming a ComponentTable, finding which field, confirming
it's actually indexed. Doing that lookup by name on every call costs dictionary/string work for no reason, since the
target never changes between calls. IndexRef separates the two: pay the resolution cost once, then carry a cheap,
fixed-size handle into the hot path indefinitely.
โ๏ธ How it works (in brief)
DatabaseEngine.GetPKIndexRef<T>() and DatabaseEngine.GetIndexRef<T, TKey>(selector) are the only ways to obtain an
IndexRef. Both validate eagerly โ component registered, field exists, field carries [Index] โ and throw immediately
on failure rather than deferring the error to first use. On success they return a small readonly struct identifying the
target table and field, plus a snapshot of the table's current index-layout version. Every API that accepts an
IndexRef checks that snapshot against the table's live version before use โ an O(1) integer compare โ so a handle
captured before a schema migration that reshuffles indexed fields is detected and rejected rather than silently
misreading another field.
๐ป Usage
// Cold path โ resolve once, e.g. at startup or before entering a hot loop.
var nameIndex = dbe.GetIndexRef<Player, String64>(p => p.Name);
var idIndex = dbe.GetPKIndexRef<Player>();
// Hot path โ pass the handles in repeatedly; no re-resolution, no allocation.
using (var tx = dbe.CreateQuickTransaction())
{
using var hits = tx.EnumerateIndex<Player, String64>(nameIndex, minKey: default, maxKey: String64.MaxValue);
foreach (var hit in hits)
{
// hit.EntityPK, hit.Key, hit.Component
}
}
โ ๏ธ Guarantees & limits
GetPKIndexRef<T>()/GetIndexRef<T, TKey>(selector)throwInvalidOperationExceptionimmediately if the component isn't registered, the field doesn't exist, or the field isn't[Index]-annotated โ failures surface at resolution time, not on first hot-path use.- Resolution validates against the table's current schema; any later staleness check is a single integer compare
(
CapturedLayoutVersionvs. the table's live layout version) โ no dictionary lookup, no page-cache access. - A handle becomes stale the moment its owning table's index layout changes (e.g. an index added/removed/reordered by
schema evolution). The next use throws rather than reading the wrong field; the only recovery is to call
GetIndexRef/GetPKIndexRefagain. IndexRefis a 16-bytereadonly structโ copy it into fields, locals, or closures freely; there is nothing to dispose.GetPKIndexRef<T>()resolves and reportsIsPrimaryKey == true, but primary-key lookups go through ECS entity APIs /EntityMap, notTransaction.EnumerateIndexโ passing a PKIndexRefthere throws.IndexRefis for secondary indexes in range/lookup APIs.
๐งช Tests
- IndexRefTests โ
GetPKIndexRef/GetIndexRefsuccess and failure paths (unregistered type, non-indexed field), plus theValidate()staleness check - BulkEnumerateTests โ
StaleIndexRef_Throwsexercises the same staleness check on the hot-pathTransaction.EnumerateIndexcall
๐ Related
- Sibling: Lookup and Range-Scan Operations โ the hot-path API
IndexRefhandles are resolved for - Sibling: Indexed Field Predicates (WhereField) โ
WhereFieldcompiles down to an index lookup viaIndexRef