Skip to main content

Reactive Queries

Reactive queries are public subscription facades over Z-set materialized state. Supported shapes are lowered to named materialized views or hidden compiler-owned ReactiveAutoView relations/indexes and update automatically when underlying data changes. Instead of re-executing the source query on every commit, supported maintainers apply O(changes) delta operations to the existing result. Shapes outside the current maintainer matrix fail before emission with typed diagnostics.

See also: Transactions · PGO · Navigation Editors · Query Language


Overview

Schema-First Materialized Views

The new schema-first materialization path is based on materialized view declarations and Z-set deltas. A materialized view is a derived in-memory relation over source-of-truth base tables; it does not have persistence, type_id, schema_version, WAL entries, snapshots, TTL, or cache semantics.

Reactive queries over materialized views read and subscribe through the same materialized relation or materialized index slice used by ordinary compiled queries. That materialized facade is the reactive execution contract: generated reactive code reads maintained Z-set relation/index state and must not emit non-materialized reactive execution code.

Reactive queries over ordinary tables or virtual views do not require users to hand-declare a materialized view just to get incremental behavior. If the query shape is supported by the Z-set maintainer matrix, the schema binder creates an internal hidden materialized view with origin ReactiveAutoView. It is private compiler-owned state: it has no persistence, no public schema name, no WAL or snapshot metadata, and no DbSet<T>. The reactive query reads/subscribes to that hidden relation or index, and ordinary compiled queries can reuse the same hidden state when their typed normalized body is proven equivalent and the materialized access is costed as cheaper than the base plan or exactly matches a maintained index. Failed equivalence proof is not an error; the ordinary query keeps its base-table plan.

Explain/debug output makes hidden reuse explicit with the hidden view id, origin: ReactiveAutoView, visibility: Internal, ownerReactiveQuery, and rewriteSource: ReactiveAutoView or rewriteSource: NamedAutoRewrite, plus the proof kind, selected/base cost, and selected relation/index access. Shapes that are not covered by the Z-set maintainer matrix fail with a typed compiler diagnostic before emission.

The Problem

One-shot query execution evaluates the source pipeline on each call:

Commit() -> Query executes source pipeline -> Result

For a 10,000-row table queried 60 times/second, this means 600,000 row evaluations per second — most of which are wasted because only a few rows changed.

The IVM Solution

Reactive queries subscribe to data changes and apply incremental deltas:

Commit() → Z-set maintenance → O(changes) updates → Result (always current)

For supported shapes on the same 10,000-row table with 5 changes per frame, this drops to 300 evaluations per second — a 2000x reduction.

Architecture

┌──────────────────────────────────────────────────────────────────────┐
│ Compile-Time (Code Generation) │
│ │
│ DSL Query ──> Hidden/Named Materialized View ──> Z-set Plan │
│ (ReactiveAutoView or user MV) + Index Selection │
└─────────────────────────────────────┬────────────────────────────────┘
│ Generated C# code
v
┌──────────────────────────────────────────────────────────────────────┐
│ Runtime (Delta Application) │
│ │
│ Commit() ──> Base-table ZDelta ──> MaterializedRelation.Apply │
│ ──> Materialized Indexes │
│ ──> Reactive facade buffers │
│ │
│ Subscribers: Subscribe() / SubscribeSpan() / SubscribeChanged() │
└──────────────────────────────────────────────────────────────────────┘

Quick Start

1. Define a Reactive Query

reactive query TopPlayersByGuild(guild_id: int) -> Player[] =
from Players
| filter GuildId == @guild_id
| sort -Level
| take 10
| select { Name = Name, Level = Level }

2. Access the Materialized View

Reactive queries are reached through generated accessors. Context-level queries (projections, aggregates, or multi-table joins) live on the per-context query host, reached via context.QueryHost<...>(); simple single-table reactive queries are generated on the corresponding DbSet. The query-host interface is generated per context (e.g. IGameDbContextQueries).

// Context-level reactive query — reached via the generated per-context query host.
var queries = context.QueryHost<IGameDbContextQueries>();
var view = queries.TopPlayersByGuild(guildId: 5);

// Read current results (zero-copy span)
ReadOnlySpan<PlayerView> topPlayers = view.Current;
foreach (var p in topPlayers)
Console.WriteLine($"{p.Name}: Level {p.Level}");

// Subscribe to changes
view.Subscribe(snapshot =>
{
// Called automatically when Players table changes
RefreshUI(snapshot.Span);
});

3. Results Update Automatically

context.BeginTransaction();
context.Players.Update(playerId, player with { Level = player.Level + 1 });
context.Commit();
// view.Current now reflects the level-up — no re-query needed

Subscription Modes

ConjureDB provides three subscription modes optimized for different use cases:

Subscribe(ChangeHandler<ReadOnlyMemory<T>>) — Snapshot Mode

Receives an immutable memory snapshot after each delta application. Safe to store and read from any thread.

view.Subscribe(snapshot =>
{
// snapshot is a ReadOnlyMemory<T> — immutable, thread-safe
UpdateLeaderboardUI(snapshot);
});

Use when: You need to pass the result to another thread (e.g., render thread), or store it for later comparison.

SubscribeSpan(SpanChangeHandler<T>) — Zero-Allocation Mode

Receives an ephemeral ReadOnlySpan<T>. No allocation — the span points directly into the result container's backing array.

view.SubscribeSpan(span =>
{
// span is only valid during this callback — do NOT store it
for (int i = 0; i < span.Length; i++)
RenderRow(i, span[i]);
});

Use when: Performance is critical (game loop, 60 fps). Main thread only.

SubscribeChanged(ReactiveQueryChangedHandler<T>) — Delta Mode

Receives both the delta payload (what changed) and the current snapshot.

view.SubscribeChanged(changes =>
{
if (changes.IsReset)
{
RebuildUI(changes.Current);
return;
}

// Incremental UI update
foreach (var change in changes.Changes)
{
switch (change.Action)
{
case ReactiveChangeAction.Add:
InsertRow(change.NewItem);
break;
case ReactiveChangeAction.Remove:
RemoveRow(change.EntityId);
break;
case ReactiveChangeAction.Replace:
UpdateRow(change.EntityId, change.NewItem);
break;
}
}
});

Use when: You need to know what specifically changed (e.g., animate additions/removals in a list view).

Lifetime: The ReactiveQueryChangedEventArgs<T> is a stack-only ref struct; its Current and Changes spans are valid only during the callback and reference live worker buffers. Copy out any data you need to retain before returning — the payload cannot be stored or passed to another thread.

Subscription Comparison

ModeAllocationThread SafetyChange DetailsBest For
Subscribe1 copy (Memory)✅ Cross-thread❌ Snapshot onlyMulti-threaded consumers
SubscribeSpanZero❌ Main thread only❌ Snapshot onlyGame loop (60 fps)
SubscribeChangedZero❌ Main thread only✅ Delta + snapshotAnimated UI

Plan Classification

At compile time, the compiler classifies each reactive query's physical plan to determine the most efficient delta handling strategy. Classification is a structural ratchet — the system picks the most incremental strategy that the query shape supports.

Classification Hierarchy

The compiler automatically selects the best update strategy based on the query shape.

Query ShapeUpdate StrategyStateful
Filter + ProjectStateless filter+project
Sort + Take (TopK)Sorted top-K with overflow
DistinctReference-counting
Scalar AggregateSingleton materialized aggregate state
Grouped AggregatePer-group accumulators, including HAVING-style visibility
JoinMaterialized arrangements for inner/left/right/full/semi/anti equijoins, including supported connected graph and alias-scoped self-join provenance shapes
Row-number TopK windowPartitioned TopK state for bounded row_number <= K windows
Union AllIndependent branch maintainers
Set operationsCounted dual-branch state for union, intersect, and except variants
SubqueriesCompiler metadata exists; executable paths require decorrelation to a supported materialized maintainer
Unsupported shapesCompile-time diagnostic

Delta Handling

Each supported query shape is lowered to a Z-set materialized maintainer. The following table summarizes how source changes are applied to materialized relation/index state:

Query ShapeAddRemoveUpdateNotes
Filter + ProjectApply filter → add to resultRemove by entity IDRe-evaluate filter; add/remove/update accordinglyStateless, O(1) per change
TopKSorted insert into maintained stateRemove + promote from overflowRemove + re-insert (position may change)Overflow buffer backs evicted items
DistinctIncrement refcount; visible when count = 1Decrement refcount; removed when count = 0Decrement old + increment newReference-counting based
Aggregate (scalar)Update singleton accumulatorUpdate singleton accumulatorRemove old + add newEmpty input keeps one scalar result row
Aggregate (grouped)Update group accumulatorsRemove group when count = 0, unless HAVING only hides itUpdate group accumulatorsPer-group tracking plus counted ordered extrema for min/max
JoinProbe/update materialized arrangementsRemove old join rows and unmatched outer rows as neededRemove old + probe/add newInner, left, right, full, semi, and anti equijoins
Row-number TopK windowInsert candidate into partitioned ordered stateRemove candidate and diff partition top-KRemove old + add newRequires one partition key, deterministic sort, and a positive literal row-number bound
Union AllIndependent per-branch maintenanceIndependent per-branch maintenanceIndependent per-branch maintenanceOne maintainer per branch
Set operationsUpdate branch counts and emit visible multiplicity diffUpdate branch counts and emit visible multiplicity diffRemove old branch row + add new branch rowunion, intersect, intersect all, except, and except all preserve set/bag semantics

Materialized Facade Buffers

Reactive queries expose ReactiveQuery<T> as a public facade over maintained materialized relation/index state. Slice buffers replay the current relation or index slice into a result container during SyncReactiveQueries(); supported paths do not execute the original source pipeline at query time.


Incrementalization Support

At compile time, the compiler reports whether each reactive query supports incremental updates:

VerdictMeaningCompile-Time Action
IncrementalFully incrementalizableGenerate hidden/named materialized relation maintenance
UnsupportedNo supported Z-set materialized maintainer exists for this shapeCompile-time rejection with a typed diagnostic

Use the ReactiveQueryDiagnostics API to inspect incrementalization decisions at runtime (see Diagnostics).


Supported and Unsupported Operators

Fully Supported (Incremental)

DSL OperatorDelta Pattern
from TableBase-table Z-delta source
filter PredicateStateless conditional
select ProjectionStateless transform
sort ... + bounded take NTopK with maintained ordered state
bounded row_number windowPartitioned TopK state for `sort
distinctRefcount-based
scalar count, sum, avg, min, maxSingleton materialized aggregate state
grouped count, sum, avg, min, maxPer-group accumulator fields plus counted ordered extrema state
join Table (condition)Materialized arrangement maintenance for inner, left, right, full, semi, and anti equijoins
union allIndependent branch maintainers
unionDual-branch counted set state with set semantics
intersect, intersect allDual-branch counted set state with set/bag intersection semantics
except, except allDual-branch counted set state with set/bag difference semantics

Conditional or Unsupported in This Release

OperatorCaveat
take (without deterministic sort)Requires a supported deterministic ordering proof; otherwise rejected
SubqueriesExpression-level subqueries are classifier/proof metadata in this release. A query is executable only when the subquery has already been lowered into an explicit supported relational operator shape, such as semi/anti join or grouped aggregate; residual expression-level subquery filters are rejected before emission

Unsupported

These operators do not have an executable Z-set maintainer in this release and fail with a typed compiler diagnostic:

OperatorReason
Unbounded or unranked window functions (rank, dense_rank, arbitrary frames, unbounded row_number)No bounded partitioned TopK maintainer
Join chains beyond the current single-join materialized maintainerRequires a generalized join-graph materialized maintainer across capability analysis, runtime state, and generated maintenance code
Correlated scalar residualsNon-FK correlation

Strict vs Permissive Mode

Strict Performance Mode

ReactiveCompilationMode.StrictPerformance is a compiler/emission setting, not an attribute.

In strict mode, any query outside the maintainer matrix is rejected at compile time with an EmissionException. This ensures that all reactive queries in the application are guaranteed to be maintained through Z-set materialized state.

// Compilation error in strict mode:
// error UM6011: Reactive query 'RankedPlayers' is not supported by the
// current Z-set reactive maintainer matrix.

Permissive Mode (Default)

Permissive mode still publishes diagnostic capability metadata for analysis, but unsupported reactive roots still fail before emission. Generated reactive code is not produced without a supported materialized maintainer.

Choosing a Mode

ScenarioRecommended Mode
Game UI (60 fps)Strict — no unsupported maintainer shapes in hot path
Analytics dashboardPermissive — richer diagnostics while unsupported shapes are implemented
Development/prototypingPermissive — faster iteration

Performance Characteristics

IVM vs Full Query Re-Execution

Table SizeChanges/CommitFull Query Re-ExecutionIVM DeltaSpeedup
1,0001~50 us~1 us~50x
1,0005~50 us~5 us~10x
10,0001~500 us~1 us~500x
10,00020~500 us~20 us~25x
100,0001~5 ms~1 us~5000x
100,000100~5 ms~100 us~50x

Cost Model

OperationTime ComplexitySpace Complexity
SFP delta (per change)O(1)O(result size)
TopK delta (per change)O(log K)O(K + overflow)
Distinct delta (per change)O(1) amortizedO(distinct values)
Aggregate delta (per change)O(1)O(groups)
Join delta (per change)O(matches)O(result + index)
Full query re-executionO(N)O(result size)

Memory Overhead

Each reactive query maintains or reuses:

  • Materialized relation — consolidated positive Z-set rows
  • Materialized index state — declared or synthesized access paths
  • Result container — proportional to visible result size
  • Delta buffer — pooled, reused across frames
  • Join arrangements — proportional to arranged source keys for join maintainers

ReactiveQuery API Reference

public class ReactiveQuery<T>
{
// Current materialized result
ReadOnlySpan<T> Current { get; }

// Database version this result reflects
long Version { get; }

// Optional keyed lookup when the underlying container supports it
bool TryGetByEntityId(int entityId, out T result);

// Subscription methods
IDisposable Subscribe(ChangeHandler<ReadOnlyMemory<T>> handler);
IDisposable SubscribeSpan(SpanChangeHandler<T> handler);
IDisposable SubscribeChanged(ReactiveQueryChangedHandler<T> handler);

// Execution priority (affects scheduling)
ReactiveQueryPriority Priority { get; }
void SetPriority(ReactiveQueryPriority newPriority);

// Runtime diagnostic snapshot
ReactiveQueryDiagnostics GetDiagnostics();
}

Priority Levels

PriorityFrame BudgetUse Case
CriticalImmediate (within commit)Active UI panel
NormalWithin frame budgetVisible but not primary
BackgroundDeferredOff-screen or analytics

Priority can be driven by PGO hints — see PGO — Reactive Query PGO Hints.


Examples

Example 1: Live Inventory View

reactive query PlayerInventory(player_id: int) -> InventoryRow[] =
from Items
| filter OwnerId == @player_id
| select { Name = Name, Quantity = Quantity, Rarity = Rarity }
| sort -Rarity, Name
| take 50
// Single-table reactive query — generated on the corresponding DbSet.
var inventory = context.Items.PlayerInventory(playerId: currentPlayer.Id);

// Initial render
RenderInventoryGrid(inventory.Current);

// Auto-update on item changes
inventory.SubscribeSpan(items =>
{
// Called when items are added, removed, or modified
// Only the changed items are processed internally — O(changes)
RenderInventoryGrid(items);
});

// When player picks up an item:
context.BeginTransaction();
context.Items.Add(new Item
{
OwnerId = currentPlayer.Id,
Name = "Legendary Sword",
Quantity = 1,
Rarity = 5
});
context.Commit();
// inventory.Current is already updated — UI refreshes via subscription

Example 2: Dashboard Counters

reactive query GuildStats() -> GuildStatsRow[] =
from Players
| group (GuildId) (aggregate {
MemberCount = count(),
AverageLevel = avg(Level)
})
// Grouped-aggregate reactive query — reached via the generated query host.
var queries = context.QueryHost<IGameDbContextQueries>();
var guildStats = queries.GuildStats();

guildStats.Subscribe(_ =>
{
var current = guildStats.Current.First(x => x.GuildId == myGuild.Id);
memberCountLabel.Text = $"Members: {current.MemberCount}";
avgLevelLabel.Text = $"Avg Level: {current.AverageLevel:F1}";
});

// The grouped aggregate row updates through the Z-set materialized facade.

Example 3: Real-Time Leaderboard

reactive query TopPlayers() -> LeaderboardRow[] =
from Players
| join Guilds g (GuildId == g.Id)
| sort -Level, Name
| take 100
| select { Name = Name, Level = Level, GuildName = g.Name }
// Multi-table (join) reactive query — reached via the generated query host.
var queries = context.QueryHost<IGameDbContextQueries>();
var leaderboard = queries.TopPlayers();

leaderboard.SubscribeChanged(changes =>
{
if (changes.IsReset)
{
leaderboardUI.Rebuild(changes.Current);
return;
}

// Common: animate individual changes
foreach (var change in changes.Changes)
{
switch (change.Action)
{
case ReactiveChangeAction.Add:
leaderboardUI.AnimateInsert(change.NewItem);
break;
case ReactiveChangeAction.Remove:
leaderboardUI.AnimateRemove(change.EntityId);
break;
case ReactiveChangeAction.Replace:
leaderboardUI.AnimateMove(change.EntityId, change.NewItem);
break;
}
}
});

Example 4: Parameterized Bounded Reactive Query

reactive query ActiveQuestsWithRewards(player_id: int) -> QuestRow[] =
from Quests
| filter PlayerId == @player_id
| filter IsActive
| select { QuestName = QuestName, Progress = Progress, TargetProgress = TargetProgress }
| sort -Progress
| take 20
// Single-table reactive query — generated on the corresponding DbSet.
var activeQuests = context.Quests.ActiveQuestsWithRewards(playerId);

// The hidden ReactiveAutoView stores the non-parameterized source shape.
// The player filter, order, and limit are applied through the materialized
// read facade, not by scanning the source table at query time.

Diagnostics and Observability

ReactiveQueryDiagnostics

var diag = view.GetDiagnostics();

Console.WriteLine($"Result count: {diag.ResultCount}");
Console.WriteLine($"Pending delta items: {diag.PendingDeltaItemCount}");
Console.WriteLine($"Memory subscribers: {diag.MemorySubscriberCount}");
Console.WriteLine($"Version: {diag.Version}");

Monitoring Recommendations

MetricHealthyInvestigate
Delta latency (P95)< 2 ms (12.5% of frame)> 4 ms
Container size growthStableMonotonically increasing

Integration with Other Features

Transactions

Reactive query results update during Commit(). Materialized Z-set maintenance runs synchronously as part of the commit sequence. See Transactions.

PGO

PGO profiles drive reactive query buffer pre-allocation and priority suggestions. See PGO — Reactive Query PGO Hints.

Editor OnChanged / OnDeepChanged subscriptions are complementary to reactive queries. Use Editors for per-entity notifications and reactive queries for materialized views. See Navigation Editors.

Persistence

Reactive query results are not persisted — they are recomputed from the database state on startup through materialized-view initial build. Z-set maintenance owns subsequent commits. See Persistence.