Skip to content

Cratis Chronicle reactors — handle events in batches

A batch method handles the matching events from a page of Cratis Chronicle events in one call. Use it when a downstream system accepts bulk work, such as updating a search index with one request instead of a request per event. You can still report failures or delayed retries for individual partitions.

A page is a delivery containing events for one or more partitions, each identified by an event source id. A batch method receives only the event types it observes, in sequence order, and runs once for a page that contains matching events. Per-event methods continue to run once per event, partition by partition.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Observation;
using Cratis.Chronicle.Reactors;
[EventType]
public record UserRegistered(string Name);
[EventType]
public record UserRenamed(string NewName);
[EventType]
public record UserEmailChanged(string Email);
public record UserDocument(EventSourceId Id, string Name);
public record BulkUpsertFailure(EventSourceId EventSourceId, Exception Exception);
public record BulkUpsertThrottled(EventSourceId EventSourceId, TimeSpan RetryAfter);
public record BulkUpsertResponse(IEnumerable<BulkUpsertFailure> Failures, IEnumerable<BulkUpsertThrottled> Throttled);
public class UnknownUserEvent(object @event) : Exception($"Unknown user event: {@event}");
public interface ISearchIndex
{
Task<BulkUpsertResponse> BulkUpsert(IEnumerable<UserDocument> documents, CancellationToken cancellationToken);
}
public interface IEmailConfirmations
{
Task Send(EventSourceId accountId, string email);
}
public class Users(ISearchIndex index, IEmailConfirmations confirmations) : IReactor
{
[Observes<UserRegistered>]
[Observes<UserRenamed>]
public async Task Index(EventBatch batch, BatchContext context)
{
var documents = batch.Select(e => e.Event switch
{
UserRegistered registered => new UserDocument(e.Context.EventSourceId, registered.Name),
UserRenamed renamed => new UserDocument(e.Context.EventSourceId, renamed.NewName),
_ => throw new UnknownUserEvent(e.Event)
});
var response = await index.BulkUpsert(documents, context.CancellationToken);
foreach (var failure in response.Failures)
{
batch.Fail(failure.EventSourceId, failure.Exception);
}
foreach (var throttled in response.Throttled)
{
batch.Defer(throttled.EventSourceId, throttled.RetryAfter);
}
}
public Task EmailChanged(UserEmailChanged @event, EventContext context) =>
confirmations.Send(context.EventSourceId, @event.Email);
}

View C# snippet source on GitHub

Here, Index receives UserRegistered and UserRenamed events together, while EmailChanged remains a per-event method. The bulk response identifies partitions that failed or need more time; Fail and Defer let the rest of the page succeed.

A batch method takes an EventBatch as its first parameter and declares each event type it observes with [Observes<TEvent>], once per type. It may also take a BatchContext, a CancellationToken, and services from the service provider. Inside, pattern-match on the events.

Each event type assigned to a batch method must belong to that method alone. Chronicle refuses to register a reactor when:

  • An event type is observed by both a batch method and a per-event method, or by two batch methods.
  • A batch method has no [Observes<TEvent>], or a method with [Observes<TEvent>] does not take an EventBatch.
  • A per-event method takes a BatchContext.
  • A batch method returns anything but Task or void.

Returning events from a batch method is not supported. If the batch must produce events, append them through an event log supplied as a dependency.

Each method sees its events in sequence order, and so in order within each partition. Two methods of the same reactor are not ordered relative to each other, even for the same partition. When two side effects must happen in a particular order, handle them in the same method.

BatchContext describes the whole page, even when a method’s EventBatch contains only some of its events. It includes the observer, event store, namespace, event sequence, partitions, event count, and first and last sequence numbers. It also supplies a cancellation token and the deadline until which the kernel waits; a null deadline means it waits without a deadline.

Origin explains why Chronicle delivered the page:

Origin Meaning
Live The events were appended now and delivered to an observer that is up to date
CatchUp The events were read from storage for an observer that is behind
Replay The events were read from storage for an observer that was rewound
Retry The events were read from storage to recover a failed or deferred partition

Return normally without reporting a problem to mark every partition in the batch as successful. Throwing an exception fails every partition in that batch at its first event in the batch. Those partitions enter the normal failed partition recovery process.

For a partial result, report the affected partition on EventBatch:

Call Result
Fail(partition, exception) Fails that partition from its first event in the batch
Fail(eventContext, exception) Fails that event’s partition from that event; earlier events count as handled
Defer(partition, retryAfter) Delivers that partition again from its first event in the batch after the delay
Defer(eventContext, retryAfter) Delivers that event’s partition again from that event after the delay

A deferral does not count as a failure toward quarantine. Use it when a downstream system asks you to back off. A kernel that does not deliver pages cannot defer a partition; the client fails the partition instead and says why.

When several methods report a problem for the same partition, Chronicle retries from the earliest event any of them reported. That can deliver events another method already handled. Keep side effects idempotent, meaning that repeating the same work does not repeat its effect, and use the delivery identity to recognize a redelivery.