Skip to content

Concurrency

Concurrency control in Chronicle ensures that multiple operations don’t interfere with each other when appending events to the same event source. Chronicle provides a sophisticated concurrency control mechanism through the ConcurrencyScope concept, which allows you to define precisely how concurrency should be handled based on formalized event metadata tags.

A ConcurrencyScope defines the boundaries and constraints for concurrent operations when appending events. It uses formalized, well-known event metadata tags that are indexed by Chronicle to scope concurrency control to specific aspects of your events, providing fine-grained control over when concurrency violations should be detected. These tags are separate from any user-controlled event tags you may add for categorization. See Event Metadata Tags for details.

Chronicle uses the following formalized, indexed metadata tags to scope concurrency:

  • EventSourceId: Unique identifier for the event source
  • EventSourceType: Overarching, binding concept (e.g., Account)
  • EventStreamType: A concrete process related to event source type (e.g., Onboarding, Transactions)
  • EventStreamId: A marker to separate independent streams for a stream type (e.g., Monthly, Yearly)
  • EventTypes: Specific event types to scope concurrency to

The most basic form of concurrency control scopes to a specific sequence number for an event source:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
[EventType]
public record ConcurrencyAccountOpened(string AccountName);
public class ConcurrencyBankAccountService(IEventLog eventLog)
{
public async Task OpenAccount(EventSourceId accountId, string accountName)
{
var concurrencyScope = new ConcurrencyScope(
SequenceNumber: 42,
EventSourceId: accountId);
await eventLog.Append(
accountId,
new ConcurrencyAccountOpened(accountName),
concurrencyScope: concurrencyScope);
}
}

For more complex scenarios, use the ConcurrencyScopeBuilder to fluently construct concurrency scopes:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
[EventType]
public record ConcurrencyMoneyDeposited(decimal Amount);
[EventType]
public record ConcurrencyMoneyWithdrawn(decimal Amount);
public class ConcurrencyAccountTransactionService(IEventLog eventLog)
{
public async Task ProcessTransaction(EventSourceId accountId, decimal amount)
{
var concurrencyScope = new ConcurrencyScopeBuilder()
.WithEventSourceId(accountId)
.WithSequenceNumber(15)
.WithEventStreamType("Transactions")
.WithEventType<ConcurrencyMoneyDeposited>()
.WithEventType<ConcurrencyMoneyWithdrawn>()
.Build();
await eventLog.Append(
accountId,
new ConcurrencyMoneyDeposited(amount),
concurrencyScope: concurrencyScope);
}
}

You can scope concurrency to specific event source types and stream types:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
[EventType]
public record ConcurrencyAccountSettingsUpdated(string Settings);
public class ConcurrencyAccountManagementService(IEventLog eventLog)
{
public async Task UpdateAccountSettings(EventSourceId accountId, string settings)
{
var concurrencyScope = new ConcurrencyScopeBuilder()
.WithEventSourceId(accountId)
.WithEventSourceType("BankAccount")
.WithEventStreamType("AccountManagement")
.WithSequenceNumber(10)
.Build();
await eventLog.Append(
accountId,
new ConcurrencyAccountSettingsUpdated(settings),
eventSourceType: "BankAccount",
eventStreamType: "AccountManagement",
concurrencyScope: concurrencyScope);
}
}

Use event stream IDs to scope concurrency to specific streams within a stream type:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
[EventType]
public record ConcurrencyMonthlyReportGenerated(string Month);
public class ConcurrencyMonthlyReportService(IEventLog eventLog)
{
public async Task GenerateMonthlyReport(EventSourceId accountId, DateTime month)
{
var monthKey = month.ToString("yyyy-MM");
var concurrencyScope = new ConcurrencyScopeBuilder()
.WithEventSourceId(accountId)
.WithEventStreamType("Reporting")
.WithEventStreamId(monthKey)
.WithSequenceNumber(5)
.Build();
await eventLog.Append(
accountId,
new ConcurrencyMonthlyReportGenerated(monthKey),
eventStreamType: "Reporting",
eventStreamId: monthKey,
concurrencyScope: concurrencyScope);
}
}

Scope concurrency to specific event types to allow concurrent operations on different types of events:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
[EventType]
public record ConcurrencyPaymentProcessed(decimal Amount);
[EventType]
public record ConcurrencyPaymentFailed(decimal Amount);
[EventType]
public record ConcurrencyPaymentRefunded(decimal Amount);
public class ConcurrencyAccountService(IEventLog eventLog)
{
public async Task ProcessPayment(EventSourceId accountId, decimal amount)
{
// Only check concurrency for payment-related events
var concurrencyScope = new ConcurrencyScopeBuilder()
.WithEventSourceId(accountId)
.WithSequenceNumber(20)
.WithEventType<ConcurrencyPaymentProcessed>()
.WithEventType<ConcurrencyPaymentFailed>()
.WithEventType<ConcurrencyPaymentRefunded>()
.Build();
await eventLog.Append(
accountId,
new ConcurrencyPaymentProcessed(amount),
concurrencyScope: concurrencyScope);
}
}

When appending multiple events, you can specify different concurrency scopes for different event sources:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
[EventType]
public record ConcurrencyMoneyWithdrawnForTransfer(decimal Amount);
[EventType]
public record ConcurrencyMoneyDepositedForTransfer(decimal Amount);
public class ConcurrencyTransferService(IEventLog eventLog)
{
public async Task TransferMoney(EventSourceId fromAccount, EventSourceId toAccount, decimal amount)
{
var events = new[]
{
new EventForEventSourceId(fromAccount, new ConcurrencyMoneyWithdrawnForTransfer(amount)),
new EventForEventSourceId(toAccount, new ConcurrencyMoneyDepositedForTransfer(amount))
};
var concurrencyScopes = new Dictionary<EventSourceId, ConcurrencyScope>
{
[fromAccount] = new ConcurrencyScopeBuilder()
.WithEventSourceId(fromAccount)
.WithSequenceNumber(50)
.WithEventType<ConcurrencyMoneyWithdrawnForTransfer>()
.Build(),
[toAccount] = new ConcurrencyScopeBuilder()
.WithEventSourceId(toAccount)
.WithSequenceNumber(25)
.WithEventType<ConcurrencyMoneyDepositedForTransfer>()
.Build()
};
await eventLog.AppendMany(events, concurrencyScopes: concurrencyScopes);
}
}

You can also use concurrency scopes with event source operations:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Operations;
[EventType]
public record ConcurrencyAccountValidated;
[EventType]
public record ConcurrencyAccountProcessed;
public class ConcurrencyBatchAccountProcessor(IEventLog eventLog)
{
public async Task ProcessAccountBatch(EventSourceId accountId)
{
await eventLog
.ForEventSourceId(accountId, source => source
.WithConcurrencyScope(scope => scope
.WithSequenceNumber(30)
.WithEventType<ConcurrencyAccountProcessed>()
.WithEventType<ConcurrencyAccountValidated>())
.Append(new ConcurrencyAccountValidated())
.Append(new ConcurrencyAccountProcessed()))
.Perform();
}
}

When a concurrency violation occurs, Chronicle will return a ConcurrencyViolation in the append result:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
[EventType]
public record ConcurrencySafeAccountOpened(string AccountName);
public class ConcurrencySafeAccountService(IEventLog eventLog)
{
public async Task<bool> TryOpenAccount(EventSourceId accountId, string accountName)
{
var concurrencyScope = new ConcurrencyScope(
SequenceNumber: EventSequenceNumber.BeforeFirst, // Expect no event for this account yet
EventSourceId: accountId);
var result = await eventLog.Append(
accountId,
new ConcurrencySafeAccountOpened(accountName),
concurrencyScope: concurrencyScope);
if (result.HasConcurrencyViolations)
{
// result.ConcurrencyViolation describes the expected vs actual sequence number
return false;
}
// False would mean nothing was compared against the event store
return result.IsSuccess && result.ConcurrencyCheckPerformed;
}
}

Chronicle provides built-in concurrency strategies:

This strategy gets the current tail sequence number and uses it as the expected sequence number:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
[EventType]
public record ConcurrencyAccountNameChanged(string NewName);
// Configured automatically when using dependency injection
public class ConcurrencyOptimisticAccountService(IEventLog eventLog, IConcurrencyScopeStrategies strategies)
{
public async Task UpdateAccount(EventSourceId accountId, string newName)
{
var strategy = strategies.GetFor(eventLog);
var concurrencyScope = await strategy.GetScope(accountId);
await eventLog.Append(
accountId,
new ConcurrencyAccountNameChanged(newName),
concurrencyScope: concurrencyScope);
}
}

Note: [EventStreamType] on a reactor or reducer scopes which events an observer receives (see Filter by event stream type) — it does not set the EventStreamType used for concurrency scoping on an append call. Set eventStreamType explicitly on Append/AppendMany, or through ConcurrencyScopeBuilder.WithEventStreamType(...), when you need concurrency scoped to a specific stream type.

The First Append Into a Scope Is Not Checked by Default

Section titled “The First Append Into a Scope Is Not Checked by Default”

A concurrency check needs something to compare against, and the optimistic strategy gets one by reading the tail through the scope’s own narrowing. When nothing matching that narrowing has been appended yet there is no tail to read, so the strategy reports EventSequenceNumber.Unavailable — the same value that means “no expected sequence number was supplied at all”. The kernel cannot act on that, so it skips the check, notes it in a debug-level log line, and lets the append through — the skip is the designed default, so it is not logged as a warning.

yes

no

yes

no

no, the default

yes

no

yes

Append with a concurrency scope

Does any event match the scope's narrowing?

Expected tail resolved

Has anything matching been appended since?

Appended

Concurrency violation

First-append check turned on?

Check skipped, logged at debug

Does anything match it now?

This is not limited to narrow scopes — it is every first append into a scope:

  • The first event on a new event source has no tail, so its append is not checked.
  • The first event carrying a new EventSourceType, EventStreamType, or EventStreamId is not checked either, even when the event source itself already has plenty of events under a different narrowing.

The second case is the one to watch. It is the append most exposed to a race — it opens a new partition on a stream that other writers are already using — and by default it is the one the check does not cover.

Turn the check on and the strategy stops saying “no sequence number” for an empty narrowing and starts saying what a first append actually expects: no event matching this narrowing may exist. The kernel checks that, and rejects the append with a ConcurrencyViolation if a matching event turned up between the scope being resolved and the append arriving.

There are two levels, and you can use either or both:

using Cratis.Chronicle;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.EventSequences.Concurrency;
public static class ConcurrencyFirstAppendCheckSetup
{
// Application-wide: every first append into a scope the optimistic strategy resolves is checked.
public static void ConfigureChronicle(ChronicleOptions options) =>
options.ConcurrencyOptions.CheckFirstAppendIntoAScope = true;
}
[EventType]
public record ConcurrencyFirstAppendPartitionOpened(string Name);
public class ConcurrencyFirstAppendPartitionService(IEventLog eventLog)
{
// Per append: ask for the same check on one behavior, without turning it on everywhere.
public async Task<bool> TryOpenPartition(EventSourceId accountId, string name)
{
var concurrencyScope = new ConcurrencyScopeBuilder()
.ExpectingNoMatchingEvent()
.WithEventSourceId(accountId)
.WithEventType<ConcurrencyFirstAppendPartitionOpened>()
.Build();
var result = await eventLog.Append(
accountId,
new ConcurrencyFirstAppendPartitionOpened(name),
concurrencyScope: concurrencyScope);
return result.IsSuccess;
}
}
  • Application-wideConcurrencyOptions.CheckFirstAppendIntoAScope. Every scope the optimistic strategy resolves gets the check.
  • Per appendConcurrencyScopeBuilder.ExpectingNoMatchingEvent(). One behavior asks for the check without changing the default for everything else.

By default a concurrency scope protects a sequence you are extending. With the check opted into, EventSequenceNumber.BeforeFirst means before the first event, so a scope can also say “no event matching this narrowing may exist” — which makes “only one writer may open this partition” expressible as a concurrency concern.

Reach for a constraint instead when the rule has to hold for every writer forever, regardless of what any client asked for: a unique event type constraint permits exactly one event of a given type per event source, and the kernel enforces it whether or not the append declared a scope.

Telling a Skipped Check From a Passing One

Section titled “Telling a Skipped Check From a Passing One”

A skipped concurrency check and a passing one produce the same successful append, so the append result says which happened. IAppendResult.ConcurrencyCheckPerformed is true when the scope was actually compared against the event store, and false when nothing was:

  • the append asked for no check (ConcurrencyScope.None), or carried no scope at all;
  • the append was a first append into a scope and the check is not turned on — the default;
  • the append declared a scope that narrows but names no expectation, built by hand without resolving one. That is always skipped, because EventSequenceNumber.Unavailable is the same value ConcurrencyScope.None carries and cannot be read as an expectation.

Assert on it in a test when a command’s correctness depends on the check running. It is also how you confirm an opt-in actually took effect.

A scope declares “I expect no matching event” in a field of its own on the wire, and sends EventSequenceNumber.Unavailable as its sequence number. EventSequenceNumber.BeforeFirst is an in-process value and never crosses the wire. That is what makes a client/kernel version mismatch safe in both directions when the check is opted into:

ClientKernelFirst append into a narrowed scope, with the check on
currentcurrentChecked. Rejected if a matching event arrived in between
currentolderSkipped, with the SkippingIncompleteConcurrencyScope warning — the older behavior. The older kernel does not know the field, reads Unavailable, and declines to validate exactly as it always did
oldercurrentSkipped, same log line — at debug level, since for a current kernel this is the designed default. The older client never sets the field, so the kernel has no expectation to check

A skew therefore degrades to the same skip you get with the check off, and says so in the log — it never produces a check that reports success without running. Both directions are pinned by specs under for_ConcurrencyScopeConverters/when_a_client_and_kernel_version_disagree.

One caveat on the diagnostic itself: IAppendResult.ConcurrencyCheckPerformed is sent by the kernel, so a current client against an older kernel always reads false — the older kernel does not send the field. That is correct for a skipped first append and conservative for every other append (it under-reports a check that did run). It never over-reports.

  1. Use specific scoping: Scope concurrency as narrowly as possible to avoid unnecessary blocking — but note that the narrower the scope, the more scopes there are, and the first append into each one is not checked unless you ask for it
  2. Event type scoping: When possible, scope to specific event types to allow concurrent operations on different event types
  3. Handle violations gracefully: Always check for concurrency violations and implement appropriate retry or fallback logic
  4. Use builders for complex scopes: The ConcurrencyScopeBuilder provides a clear, fluent API for complex concurrency requirements
  5. Use concurrency scope strategies: Inject IConcurrencyScopeStrategies to get a built-in strategy (such as optimistic concurrency) instead of hand-rolling sequence-number lookups
  6. Assert the check ran: Read IAppendResult.ConcurrencyCheckPerformed when a behavior depends on serialization — a skipped check looks exactly like a passing one
  7. Adopt the first-append check per behavior: Turn on the first-append check where two writers must not both open the same partition, ahead of it becoming the default in the next major version