Skip to content

Concurrency

Chronicle’s concurrency control rejects an append when its checked expectation no longer matches the protected history. Atomic append alone does not bind a decision to the state it read. A ConcurrencyScope defines the boundaries for that check — which stream type, stream id, and event source type form the concurrency boundary.

On model-bound commands, you declare concurrency intent directly on the command record using attributes and interfaces. Chronicle then builds the ConcurrencyScope automatically when appending the events returned by Handle(). No manual scope construction is required.

A Dynamic Consistency Boundary is the guarantee that an append fails if an event matching the decision’s query arrived after the position it read; choosing a mechanism compares the ways to get it. The attributes on this page are the string form of the boundary. An event source definition declares the same dimensions once, with a named stream, and is the typed alternative.

A guarded generated append target is an exception to automatic tail capture: Arc uses an immutable whole-source BeforeFirst scope. Existing events produce ConcurrencyViolation with ReasonDetail = "GeneratedIdentifierCollision", regardless of routing metadata.

That automatic path is otherwise the default. If the decision must remain bound to a revision it already read, return EventsWithConcurrencyScopes and supply that exact revision with the returned events.

Three attributes control concurrency scope declaration on a command. Each attribute serves a dual purpose: it tags the appended events with metadata and, when concurrency: true is set, contributes that metadata to the concurrency scope.

Scopes concurrency to a specific event stream id within a stream type. Use this when independent streams within the same stream type should not interfere with each other.

using Cratis.Arc.Commands.ModelBound;
using Cratis.Chronicle.Events;
[Command]
[EventStreamId("customer-profile", concurrency: true)]
public record UpdateCustomerProfile(EventSourceId CustomerId, string DisplayName)
{
public CustomerDisplayNameChanged Handle() => new(DisplayName);
}
/// <summary>
/// Records the customer's updated display name.
/// </summary>
[EventType]
public record CustomerDisplayNameChanged(string DisplayName);

Scopes concurrency to a named stream type. Stream types group related streams — for example, separating Onboarding events from Transactions for the same customer.

using Cratis.Arc.Commands.ModelBound;
using Cratis.Chronicle.Events;
[Command]
[EventStreamType("Transactions", concurrency: true)]
public record ProcessPayment(EventSourceId AccountId, decimal Amount)
{
public PaymentProcessed Handle() => new(Amount);
}
/// <summary>
/// Records the amount of a processed account payment.
/// </summary>
[EventType]
public record PaymentProcessed(decimal Amount);

Scopes concurrency to a named event source type. This is the overarching concept the event source belongs to — for example Customer or BankAccount.

using Cratis.Arc.Commands.ModelBound;
using Cratis.Arc.Chronicle.Commands;
using Cratis.Chronicle.Events;
[Command]
[EventSourceType("Customer", concurrency: true)]
public record RegisterCustomer(EventSourceId CustomerId, string Email)
{
public CustomerRegistered Handle() => new(Email);
}
/// <summary>
/// Records the email address supplied when a customer registered.
/// </summary>
[EventType]
public record CustomerRegistered(string Email);

You can combine multiple concurrency attributes to build a precise scope. Only the attributes with concurrency: true contribute to the scope the command declares — but the others still tag the appended events, and the fallback strategy narrows by whatever tags an append carries, so they are not concurrency-inert either. See what a routing-only tag already does below.

using Cratis.Arc.Commands.ModelBound;
using Cratis.Arc.Chronicle.Commands;
using Cratis.Chronicle.Events;
[Command]
[EventStreamId("customer-profile", concurrency: true)]
[EventStreamType("Profile", concurrency: true)]
[EventSourceType("Customer", concurrency: true)]
public record UpdateCustomerProfile(EventSourceId CustomerId, string DisplayName, string Email)
{
public IEnumerable<object> Handle() =>
[
new CustomerDisplayNameChanged(DisplayName),
new CustomerEmailChanged(Email)
];
}
/// <summary>
/// Records the customer's updated display name.
/// </summary>
[EventType]
public record CustomerDisplayNameChanged(string DisplayName);
/// <summary>
/// Records the customer's updated email address.
/// </summary>
[EventType]
public record CustomerEmailChanged(string Email);

If no attribute has concurrency: true, the command contributes no scope of its own and the append is left to the concurrency strategy configured on the event sequence — by default the optimistic one, which resolves the expected tail for the event source being appended to, narrowed by whatever routing metadata the command carries.

A metadata attribute declared without concurrency: true still narrows the concurrency check. Its value reaches the append regardless of the flag, and the fallback strategy resolves the expected tail with the same narrowing — so the flag governs whether the command declares a scope, while the tag governs what the check is narrowed by. Both are true at once.

Three consequences follow, and the third is the surprising one:

  • A routing-only tag silently narrows every concurrency check on that command. [EventStreamType("Attachments")] with no flag restricts the expected tail to Attachments events, so a concurrent append to the same event source under a different stream type is invisible to the check.
  • Declaring concurrency: true on every metadata attribute uses the same narrowing dimensions as declaring it on none, under the default optimistic strategy. The declared scope passes the context values; the fallback passes the same values, with a sentinel standing in for anything absent — and a sentinel adds no filter. The capture timing differs: Arc resolves an explicit scope while processing the response; Chronicle resolves a missing scope during the unit of work’s commit-time AppendMany(). An intervening append can conflict with the earlier expectation but be included in the later fallback tail.
  • Declaring it on a subset produces a strictly broader scope than declaring it on none. The declared scope passes null for every dimension that did not opt in, while the fallback would have passed its real value. Declaring it on [EventStreamType] alone, on a command that also carries [EventSourceType("X")], drops the EventSourceType == "X" filter and widens the check.

When the event stream id is determined at runtime rather than as a constant, implement ICanProvideEventStreamId and return the id from GetEventStreamId().

using Cratis.Arc.Commands.ModelBound;
using Cratis.Arc.Chronicle.Commands;
using Cratis.Chronicle.Events;
[Command]
[EventStreamType("Reporting", concurrency: true)]
public record GenerateMonthlyReport(EventSourceId AccountId, string MonthKey)
: ICanProvideEventStreamId
{
public EventStreamId GetEventStreamId() => MonthKey;
public MonthlyReportGenerated Handle() => new(MonthKey);
}
/// <summary>
/// Records the month covered by a generated account report.
/// </summary>
[EventType]
public record MonthlyReportGenerated(string MonthKey);

Note: If both a non-empty [EventStreamId] value and ICanProvideEventStreamId are present on the same command, Chronicle throws an AmbiguousEventStreamId exception. Choose one approach, or set the attribute value to null to defer to the interface.

When a stream id combines command properties, use a template instead of implementing ICanProvideEventStreamId. Templates are resolved for Arc commands only. On a Chronicle reactor, the same [EventStreamId] value is used literally; it does not interpolate reactor properties.

using Cratis.Arc.Chronicle.Commands;
using Cratis.Arc.Commands.ModelBound;
using Cratis.Chronicle.Events;
[Command]
[EventStreamType("Reporting")]
[EventStreamId("{ReportingScopeId}:{Period}")]
public record GenerateReport(EventSourceId AccountId, string ReportingScopeId, DateOnly Period)
{
public ReportGenerated Handle() => new(Period);
}
[EventType]
public record ReportGenerated(DateOnly Period);

{Name} selects a readable public instance property. Each part is formatted as follows:

Part typeFormat
ConceptIts underlying value, using the rules below
stringUnchanged
EnumIts name
DateOnlyyyyy-MM-dd
DateTime, DateTimeOffset, TimeOnlyRound-trip O, preserving seconds and fractional ticks
TimeSpanInvariant c
Other IFormattable values, including numbers and GuidDefault format with invariant culture
Other valuesToString()

{{ and }} escape literal braces within a template. Unknown properties, null or blank parts, malformed braces, and a resolved id equal to Default or empty are refused. Arc resolves template parts on first route use after authorization and validation, so a validator can reject a missing part without a template exception. Constant values and ICanProvideEventStreamId keep their existing behavior.

EventStreamIdTemplate.Resolve("{ReportingScopeId}:{Period}", command) resolves a template outside the pipeline. EventStreamIdTemplate.ResolveFor(command) resolves the command’s attribute or provider interface exactly as the pipeline does, and returns null when neither declares an id. Use the same function for a read or seed route instead of repeating the join. An attribute with a value and ICanProvideEventStreamId still raises AmbiguousEventStreamId.

Owner-qualified templates are not needed to scope a stream completion: when the command has an event source id, Arc closes only that source’s stream. Keep a composite id when those properties genuinely identify the stream.

The event source id used when appending is resolved from the command by convention — not from the concurrency scope. See Event Source Id Resolution for the full resolution order, including ICanProvideEventSourceId.

When Handle() returns events, Chronicle inspects the command type for the three concurrency attributes. It reads the resolved metadata values from the command context and builds a ConcurrencyScope covering only the metadata where concurrency: true was set.

Two properties of that scope decide whether the check actually happens, and both are resolved per append rather than once per command:

  • It carries a checked expectation, resolved by the event sequence’s strategy. An actual revision is checked and BeforeFirst protects empty matching history. Unavailable is not an empty-history expectation; incomplete expectations are skipped/reported rather than enforcing the invariant. Automatic first-append checking is opt-in through the client’s concurrency options.
  • It is bound to the event source being appended to. A command that appends across streams gets a scope per target, because an expected tail belongs to exactly one stream; applying one stream’s tail to another would be wrong for both.

Carrying the revision used by the decision

Section titled “Carrying the revision used by the decision”

With an opted-in concurrency attribute, automatic optimistic concurrency resolves the expected tail while Arc handles the returned value. With no opted-in attribute, Arc leaves the scope unset and Chronicle resolves the fallback expectation during commit-time AppendMany(). Neither capture is necessarily the revision Handle() read. A concurrent append that lands after that read but before the applicable capture can become part of the new expected tail rather than cause a conflict.

When that newer tail would invalidate the decision, capture the revision during the read and return it in EventsWithConcurrencyScopes. Arc passes it unchanged into the same command transaction as the ordered events. Interference after the read then produces a concurrency validation failure at commit, with no partial append.

For a decision over stream facts, IStreamReads captures the tail before reading the events and returns a StreamDecision. Its Append(...) routes the facts and builds the exact scope, including the empty-stream first-append check.

This is opt-in. Returning ordinary events or EventForEventSourceId values keeps the automatic strategy and its existing behavior. For an empty read, explicitly convert Unavailable to BeforeFirst as shown in the exact-scope example, and deploy a compatible server. A scope watching one event type cannot enforce a rule over changes outside that narrowing.

When Handle() returns EventsWithConcurrencyScopes, its supplied scopes replace automatic capture. For a guarded generated target, an equal whole-source BeforeFirst intent merges with the freshness guard; a differing explicit intent fails with ConflictingConcurrencyScopes. Remove concurrency: true from [EventSourceType], [EventStreamType] and [EventStreamId] on that command, but keep their routing values. A later automatically captured tail would defeat the purpose of guarding the revision used by the decision.

For [EventSource<TSource>(stream)], keep the concurrency dimensions on the shared definition. The exact returned scope intentionally takes precedence for this command without changing the policy for other commands using the same definition. Chronicle carries one scope per event source id per command, so two stream decisions for the same source cannot supply independent guards.