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; } } }}Guarantees
Section titled “Guarantees”- 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
BootstrapStatusChangedevent fires at the right moments to switch between full-replace and incremental modes.
Trade-offs
Section titled “Trade-offs”- 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); } } }}Guarantees
Section titled “Guarantees”- Every change that occurs while the subscriber is connected is delivered exactly once.
PreviousEntityprovides the old state for computing diffs.
Trade-offs
Section titled “Trade-offs”- 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();}Guarantees
Section titled “Guarantees”- 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.
Trade-offs
Section titled “Trade-offs”- 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.
Choosing a pattern
Section titled “Choosing a pattern”| Requirement | Pattern |
|---|---|
| Local copy must match server exactly at all times | Mirror database with Full re-bootstrap |
| React to individual changes, no local copy needed | Event-driven with LiveOnly |
| Periodic sync, not real-time | One-shot snapshot |
| Real-time dashboard, accept brief gaps on reconnect | Live-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.