Persistent Views β Incremental Refresh & Delta Tracking
A live, indexed query result set that updates itself in microseconds instead of being re-run every tick.
Status: π§ Partial Β· Visibility: Public Β· Level: π΅ Core Β· Category: Querying
Assumes: Indexed Field Predicates (WhereField)
π― What it solves
Game loops and UI panels routinely need to know "which entities currently match this condition" once per tick β active quests, nearby enemies, low-health units. Re-running the query every tick against the full table wastes CPU on entities that didn't change, and that cost grows with table size, not with how much actually moved. Persistent Views hold the matching entity set across ticks and update only the entities affected by commits since the last refresh, turning a per-tick cost proportional to table size into one proportional to the number of changes.
βοΈ How it works (in brief)
ToView() on an indexed-field query (WhereField) builds the initial entity set once via the same selectivity-driven execution plan as a one-shot query, then registers the view to receive change notifications for every field it depends on. At commit time, the field-update path that already touches old/new values for the B+Tree index also pushes a small delta record (entity, before-key, after-key) into the view's lock-free multi-producer/single-consumer ring buffer β no extra scan, no polling. Refresh(tx) drains that buffer up to the transaction's snapshot, re-evaluates only the changed entities' predicates, and updates the entity set plus an Added/Removed/Modified delta. If the buffer fills up between refreshes (too many changes, too long a gap), the view self-heals by falling back to one full re-query using its cached execution plan, then resumes incremental mode.
π» Usage
using var view = tx.Query<ItemArch>()
.WhereField<ItemData>(i => i.Rarity >= 3)
.ToView(); // β EcsView<ItemArch>, populated immediately
// Game loop
while (running)
{
using var refreshTx = dbe.CreateQuickTransaction();
view.Refresh(refreshTx);
var delta = view.GetDelta();
foreach (var pk in delta.Added) SpawnVisual(pk);
foreach (var pk in delta.Removed) DespawnVisual(pk);
foreach (var pk in delta.Modified) UpdateVisual(pk);
view.ClearDelta();
// Or iterate the current full entity set
foreach (var id in view.GetEntityEnumerator()) { /* ... */ }
}
| Option | Default | Effect |
|---|---|---|
ToView(bufferCapacity:) |
4096 (power of 2) | Ring buffer slot count; larger absorbs bigger inter-refresh bursts before overflowing, at ~35 bytes/slot unmanaged memory |
EcsView<TArchetype> picks its refresh strategy from the predicate shape at ToView() time:
| Mode | Created when | Refresh cost |
|---|---|---|
| Incremental | Single WhereField branch |
O(changes since last refresh) |
| OR | WhereField with \|\| (multiple branches, max 16) |
O(changes), per-entity branch bitmap |
| Pull | No WhereField (opaque Where or no predicate) |
O(full result set), every call β correct, but not incremental |
β οΈ Guarantees & limits
- Indexed fields only. Every field in the view's predicate must be
[Index]-annotated β that's what gives commit-time change capture for free. Non-indexed fields are rejected atToView()time; use.Where(lambda)one-shot queries for those instead. - No held transaction. A view tracks a TSN watermark, not an open transaction β it never blocks MVCC cleanup, however long it lives.
- Refresh is explicit and consumer-controlled. Views never update themselves; call
Refresh(tx)whenever the caller wants to observe the latest committed state up totx's snapshot. - Delta is net change since
ClearDelta(), not a raw log β an entity that enters and leaves between twoClearDelta()calls produces no event; one that leaves and re-enters reports as Modified. ViewDelta/GetDelta()is zero-allocation and references the view's internal state directly; it is only valid until the nextClearDelta().- Overflow is graceful, not fatal. A full ring buffer sets
HasOverflow; the nextRefresh()rebuilds the entity set from the cached execution plan and computes Added/Removed from the diff β but per-field Modified granularity for that cycle is lost. - AND, OR, and plain-scan (pull) predicates are all supported by the same
EcsView<TArchetype>type β the refresh mode is selected automatically from the predicate shape; OR predicates are capped at 16 DNF branches. - Not ordered. A view is an unordered live set;
OrderByField/Skip/Takeare rejected onToView(). Re-runExecuteOrdered()per cycle if you need a sorted/paged snapshot. - Must be disposed.
view.Dispose()deregisters it from change notifications and frees its unmanaged ring buffer; un-drained entries are discarded. - Cost profile: commit-time notification is sub-microsecond per (changed field, watching view); draining ~100-200 changes on refresh is single-digit microseconds.
- SingleVersion/Transient views are not fully validated yet. Views over these storage modes are designed to observe state only as of the last tick boundary (after
WriteTickFencedrains ring buffers), but this wiring is still partial β treat tick-boundary consistency for SV/Transient views as unvalidated until it's closed out. Views overVersionedcomponents are fully validated. This is the reason this feature is marked Partial rather than Implemented.
π§ͺ Tests
- EcsIncrementalViewTests β incremental refresh (field crosses in/out),
GetDeltaAdded/Removed/Modified,CompactDeltanet-change collapsing, overflow recovery via full re-query, Pull-mode fallback - EcsOrViewTests β OR mode per-entity branch bitmap, entity stays in view while any branch matches
- EcsViewTests β base
ToView/Refreshmechanics,Contains, delta clearing, dispose semantics - ViewChangeCaptureTests β commit-time ring-buffer delta entries (before/after keys, multi-field changes, disposed-view skip)
- ViewRegistryTests β view registration/deregistration bookkeeping behind commit-time change notification
- EcsViewMultiFieldTests β two ANDed
WhereFieldpredicates on one view: per-field crossing only re-evaluates that predicate
π Related
- Related feature: Fluent Query API & Predicate Parsing
- Also documented as: Reactive Views (EcsView) in the Ecs category β same feature, since
ToView()is called on anEcsQuery; this page is canonical. - Sibling: Published Views β registers a View as a subscribable target for connected clients