Secondary Index Storage Modes
Unique vs AllowMultiple: the per-field choice that decides an index's on-disk value shape and per-entity storage cost.
Status: โ Implemented ยท Visibility: Public ยท Level: ๐ต Core ยท Category: Indexing Assumes: Component & Field Schema Declaration
๐ฏ What it solves
Modeling a field as [Index] always builds a B+Tree, but a 1:1 field (a player ID, a SKU) and a 1:N field (a guild
ID, a status) cannot use the same on-disk value shape โ one entity per key needs no more than a pointer, many
entities per key need a growable set. The storage mode chosen for an indexed field โ unique or AllowMultiple โ
isn't just a uniqueness constraint at the application level; it picks a different B+Tree value representation,
adds (or doesn't add) a hidden per-entity bookkeeping field, and routes commits through a different compound
B+Tree operation. Picking the right mode keeps both storage footprint and commit cost proportional to what the
field actually models.
โ๏ธ How it works (in brief)
A unique index's B+Tree value is the entity's component chunk-id itself โ direct, no indirection. An
AllowMultiple index's value is a HEAD buffer ID: a VariableSizedBufferSegment holding the current set of
chunk-ids that share the key. To remove or relocate a single entity's slot inside a HEAD buffer in O(1) instead of
scanning it, every AllowMultiple-indexed field adds a hidden 4-byte ElementId to the component's storage
overhead โ unique fields add nothing. The mode also picks the commit-time B+Tree operation: unique fields use
Add/Move/Remove; AllowMultiple fields use Add/MoveValue/RemoveValue, and on Versioned components
additionally maintain a per-key TAIL history buffer. Both shapes share the identical OLC concurrency model and the
same read API (Transaction.EnumerateIndex) โ the difference is invisible to a caller and shows up only in
storage footprint and commit cost.
Sub-features
| Sub-feature | Declared as | On-disk value | Extra per-entity cost |
|---|---|---|---|
| Unique (single-value) secondary index | [Index] (default) |
Key โ chunk-id, directly | none |
| Multi-value secondary index (AllowMultiple) | [Index(AllowMultiple = true)] |
Key โ HEAD buffer of chunk-ids (+ TAIL on Versioned) |
+4 bytes (ElementId) |
โ ๏ธ Guarantees & limits
- The mode is fixed per field at schema registration; switching between unique and
AllowMultipleis a schema change, not a runtime toggle โ it bumps the table's index-layout version and invalidates every previously resolvedIndexRef. - The 4-byte
ElementIdoverhead applies to everyAllowMultiple-indexed field on every component instance, regardless of storage mode (SingleVersion/Versioned/Transient) โ it is what lets the commit path remove an entity from its HEAD buffer without a linear scan. - The TAIL history segment is allocated once per table, only if at least one
AllowMultipleindex exists on it, and each key's TAIL buffer is populated lazily on that key's first mutation โ a table with only unique indexes never allocates it. - Unique-field commits (
Move) are a single tree traversal that write-locks at most two leaves.AllowMultiplecommits (MoveValue/RemoveValue) do that same traversal plus splice the entity into/out of a HEAD buffer (and, onVersionedtables, append TAIL entries) โ strictly more work per commit for the same field shape. - Both modes expose the identical read surface โ
Transaction.EnumerateIndexover ascending key order โ so choosing a mode never changes how query code is written, only what it costs underneath. - The mode applies uniformly across all three component storage modes (
SingleVersion/Versioned/Transient); onlySingleVersion/Versionedindexes are queried viaTransaction.EnumerateIndexโTransient-indexed fields are built and maintained the same way but are read through the ECS query pipeline (WhereField) instead.
๐งช Tests
- BtreeTests โ unique vs.
AllowMultipleB+Tree value representation side by side (CheckTree/CheckRemovevs.CheckMultipleTree/CheckByteMultipleTree/CheckFloatMultipleTree) - BulkEnumerateTests โ
SecondaryIndex_UniqueField/SecondaryIndex_AllowMultipleshow both modes read through the identicalTransaction.EnumerateIndexsurface
๐ Related
- See also: Specialized B+Tree Key-Size Variants, IndexRef โ Resolve-Once Index Handle
- See also: Indexed Field Predicates (WhereField) โ the query-planner path for
Transient-indexed fields - Sub-features: Unique (single-value) secondary index, Multi-value secondary index (AllowMultiple)