Skip to content

Event Streams

Standard subscriptions deliver coalesced state notifications. Multiple writes to the same entity within a coalescing window are merged into a single notification containing the final converged state. This is the right default for most consumers, but some use cases need to observe every individual operation.

Event streams deliver a notification for every ASSERT, PATCH, and RETRACT operation, in order, without coalescing. Each event identifies the source that produced it, carries a monotonic sequence number, and includes the raw assert payload (not the merged state).

Use event streams when you need:

  • Audit logs. Record every write operation with its source identity.
  • Replay and projection. Rebuild derived state by replaying the full sequence of operations.
  • Per-source tracking. React to individual source contributions rather than the merged result.
  • External event buses. Forward individual operations to Kafka, NATS, or similar systems.

For rendering UI, syncing mirror databases, or building local caches, standard subscriptions are a better fit. They handle coalescing and convergence for you.

Event streams are opt-in per entity kind. Set EventStream = true on the attribute:

[ConvergenceEntity("PlayerAction", EventStream = true)]
public partial struct PlayerAction
{
[EntityId] public ReadOnlyMemory<byte> EntityId { get; set; }
public int ActionType { get; set; }
public float TargetX { get; set; }
public float TargetY { get; set; }
}

This flag is included in the schema registration. The server allocates the per-assert event ring buffer only for kinds that opt in. Kinds without EventStream = true pay zero cost.

Use SubscribeEventsAsync on the kind handle:

await foreach (var evt in actions.SubscribeEventsAsync(ct: ct))
{
Console.WriteLine($"[{evt.Sequence}] source={evt.SourceId} type={evt.Type}");
if (evt.Entity is { } entity)
{
Console.WriteLine($" ActionType={entity.ActionType} Target=({entity.TargetX}, {entity.TargetY})");
}
}

Event subscriptions are live-only. There is no bootstrap mode because events are ephemeral and not retained after delivery.

Each event in the stream is an EntityEvent<T> with these fields:

PropertyDescription
TypeCreated, Updated, or Retracted.
VersionThe committed entity version from the flush cycle. Multiple events in the same coalescing window share this version.
PrevVersionThe committed entity version before this flush cycle. 0 for Created events (entity did not exist). Useful for detecting gaps in event processing.
SourceIdWhich source (0-63) produced this operation.
SequencePartition-global monotonic sequence number. Provides a total ordering across all events.
EntityThe raw assert payload deserialized as T. This is the individual assert data, not the converged state. Null for Retracted events.
PreviousEntityThe entity state that ChangedFields was computed against, deserialized as T. For the first assert in a coalescing window, this is the committed entity state. For subsequent asserts, it is the accumulated state after prior asserts. Null for Created and Retracted events.
ChangedFieldsBitmask of which fields this assert changed relative to the previous state. All bits set for Created, zero for Retracted.
PresenceMaskFor PATCH operations: which fields were present in the partial update.
MetadataOpaque metadata from the assert or retract. See Metadata Passthrough.

Each update event carries both Entity (the new values) and PreviousEntity (the state before this assert). Combined with ChangedFields, these three properties form a coherent diff triple. You can see exactly what each field was before and after this specific assert:

if (evt.Type == EventType.Updated && evt.PreviousEntity is { } prev)
{
if (evt.HasChanged(PlayerAction.Fields.TargetX))
{
Console.WriteLine($"TargetX: {prev.TargetX} -> {evt.Entity!.Value.TargetX}");
}
}

EntityEvent<T> has the same HasChanged method as regular notifications:

if (evt.HasChanged(PlayerAction.Fields.TargetX))
{
Console.WriteLine("Target position changed");
}
TypeMeaning
CreatedFirst assert for a previously non-existent entity.
UpdatedAn assert or patch that modifies an existing entity.
RetractedA source retracted its assertion.

A common pattern is to process events in a background loop and forward them to an external system:

var actions = await client.RegisterKindAsync<PlayerAction>();
await foreach (var evt in actions.SubscribeEventsAsync(ct: stoppingToken))
{
switch (evt.Type)
{
case EventType.Created:
case EventType.Updated:
await externalBus.PublishAsync(new ActionEvent
{
Sequence = evt.Sequence,
SourceId = evt.SourceId,
ActionType = evt.Entity!.Value.ActionType,
TargetX = evt.Entity.Value.TargetX,
TargetY = evt.Entity.Value.TargetY,
}, stoppingToken);
break;
case EventType.Retracted:
await externalBus.PublishAsync(new ActionRetracted
{
Sequence = evt.Sequence,
SourceId = evt.SourceId,
}, stoppingToken);
break;
}
}
SubscriptionsEvent streams
GranularityOne notification per entity per flush cycle (coalesced).One event per individual ASSERT/PATCH/RETRACT.
PayloadConverged/merged entity state.Raw assert payload from a single source.
Source identitySourceSet bitmask (which sources assert the entity).SourceId (the specific source that produced this event).
OrderingPer-entity version ordering.Partition-global monotonic Sequence.
BootstrapSupported. Delivers a snapshot of all current entities.Not supported. Events are ephemeral.
Opt-inAlways available.Requires EventStream = true on the entity kind.
EpochsNotifications delivered as normal during epochs.Events suppressed during epochs (and for all server-synthesised operations).
CostDefault. No extra server-side buffering.Server allocates a per-assert event ring buffer for opted-in kinds.

Event streams only record source-originated operations. Server-synthesised operations are excluded:

  • Source epochs: Assertions, patches, and retractions made during an active epoch do not produce events. Epochs seed “current” state; they do not represent “what happened”.
  • Epoch stale retractions: When EpochEndAsync() synthesises retractions for entities not re-asserted, these do not produce events.
  • Liveness deadline retractions: When a source’s liveness timer fires and the server retracts all of its entities, these do not produce events.

In all three cases, standard subscriptions still produce Created, Updated, and Deleted notifications as normal. Only event stream recording is suppressed.

After an epoch completes, subsequent assertions from the source produce events normally. Sequence numbers remain contiguous: suppressed operations are never assigned a sequence, so event consumers see no gaps.

Event streaming is zero-cost for kinds that do not opt in. The server only allocates and maintains the event ring buffer for kinds with EventStream = true in their schema.

For opted-in kinds, the server records each individual operation into the ring buffer during the accumulation phase. This adds a small amount of memory and CPU overhead proportional to the write rate for that kind. The ring buffer is bounded, so slow event subscribers are disconnected the same way slow state subscribers are (see Backpressure).