Skip to content

Building Correct State

This page presents complete patterns for common reader use cases. Each pattern shows the full setup, explains what guarantees it provides, and identifies the trade-offs.

Pattern 1: Mirror database (zero-drift guarantee)

Section titled “Pattern 1: Mirror database (zero-drift guarantee)”

A mirror database maintains a local copy of all entities that exactly matches the server’s state. It uses ReconnectBootstrapMode.Full so that any drift caused by a disconnection is corrected automatically.

public class MirrorService(KindHandle<ClaimState> claims, AppDbContext db)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
var sub = await claims.SubscribeAsync(
bootstrap: true,
reconnectMode: ReconnectBootstrapMode.Full,
ct: ct);
var bootstrapBatch = new List<ClaimState>();
sub.BootstrapStatusChanged += status =>
{
if (status is BootstrapState.Complete
or BootstrapState.RebootstrapComplete)
{
// Bootstrap finished. Full-replace the local table.
using var tx = db.BeginTransaction();
db.BulkUpsert(bootstrapBatch);
db.DeleteWhereIdNotIn(bootstrapBatch.Select(e => e.EntityId));
tx.Commit();
bootstrapBatch.Clear();
}
};
await foreach (var change in sub.ReadAllAsync(ct))
{
if (sub.BootstrapStatus is BootstrapState.InProgress
or BootstrapState.RebootstrapInProgress)
{
// During bootstrap, buffer entities for the full-replace.
if (change.Entity is { } entity)
bootstrapBatch.Add(entity);
continue;
}
// After bootstrap, apply changes incrementally.
switch (change.Type)
{
case NotificationType.Created:
case NotificationType.Updated:
db.Upsert(change.Entity!.Value);
break;
case NotificationType.Deleted:
db.Delete(change.EntityId);
break;
}
}
}
}
  • After initial bootstrap completes, the local database contains every entity the server has.
  • During live operation, every create, update, and delete is applied incrementally.
  • After a disconnection, re-bootstrap replaces the entire local table, correcting any drift.
  • The BootstrapStatusChanged event fires at the right moments to switch between full-replace and incremental modes.
  • Re-bootstrap scans the entire entity kind. For large kinds (100K+ entities), this takes a few seconds.
  • During re-bootstrap, incremental updates are paused (buffered into the bootstrap batch).

Pattern 2: Event-driven processing (live-only)

Section titled “Pattern 2: Event-driven processing (live-only)”

An event-driven processor reacts to individual entity changes without maintaining a complete local copy. It uses ReconnectBootstrapMode.LiveOnly and does not request bootstrap.

public class InventoryEventProcessor(KindHandle<InventoryState> inventory)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var change in inventory.SubscribeAsync(
bootstrap: false,
includePrevious: true,
reconnectMode: ReconnectBootstrapMode.LiveOnly,
ct: ct))
{
if (change.Type == NotificationType.Updated
&& change.PreviousEntity is { } prev)
{
var diff = ComputeDiff(prev, change.Entity!.Value);
await ProcessInventoryChange(diff, change.Metadata);
}
}
}
}
  • Every change that occurs while the subscriber is connected is delivered exactly once.
  • PreviousEntity provides the old state for computing diffs.
  • Changes during disconnection are lost. This is acceptable when the subscriber processes transient events (for example, triggering a side effect for each inventory change) rather than maintaining a consistent view.
  • No bootstrap means the subscriber does not know the initial state. It only reacts to changes going forward.

Pattern 3: One-shot snapshot (periodic reconciliation)

Section titled “Pattern 3: One-shot snapshot (periodic reconciliation)”

A one-shot snapshot retrieves all entities at a point in time without maintaining a live subscription. Use this for periodic batch reconciliation.

// Runs on a timer (e.g., every 5 minutes)
public async Task ReconcileAsync(KindHandle<Player> players, AppDbContext db)
{
IReadOnlyList<Player> snapshot = await players.BootstrapSnapshotAsync();
using var tx = db.BeginTransaction();
db.BulkUpsert(snapshot);
db.DeleteWhereIdNotIn(snapshot.Select(p => p.EntityId));
tx.Commit();
}
  • The snapshot contains every entity that existed at the time the bootstrap scan began.
  • After applying the snapshot, the local database matches the server’s state at that point in time.
  • No live updates between snapshots. Changes between reconciliation runs are not observed until the next run.
  • Each snapshot scans the full entity kind. For large kinds, schedule this off-peak.
RequirementPattern
Local copy must match server exactly at all timesMirror database with Full re-bootstrap
React to individual changes, no local copy neededEvent-driven with LiveOnly
Periodic sync, not real-timeOne-shot snapshot
Real-time dashboard, accept brief gaps on reconnectLive-only subscription with bootstrap on initial connect

You can combine patterns. For example, use a mirror database for your primary data store and an event-driven processor for triggering side effects on changes.