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 concurrency scope is how Chronicle expresses the append condition of a Dynamic Consistency Boundary. To derive the scope from a declared event source and its streams instead of building it by hand, see Concurrency per event source and stream.
Choose between a scope and a decision read
Section titled “Choose between a scope and a decision read”| You want to guard | Use | Why |
|---|---|---|
| A rule over state you read from a projection (a count, a stock, a status) | A decision read | It captures the position before it folds, and builds the scope for you |
| A change the user saw at a revision | An explicit scope built from that revision | The position is the revision the user saw, not what the server read |
| Events you read yourself, not through a projection | An explicit scope, with the tail captured before you read | You own the capture-then-read order |
| Every writer of a stream or source seeing every other | Dimensions declared on an event source definition | The guard covers the whole range, whatever the event type |
| A value that must be unique | A constraint | The kernel checks it for every writer, with no read |
What a guard covers, and how scopes and reads merge, is in How guards are derived.
Understanding ConcurrencyScope
Section titled “Understanding ConcurrencyScope”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.
Formalized Metadata Tags for Concurrency
Section titled “Formalized Metadata Tags for Concurrency”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
Basic Usage
Section titled “Basic Usage”Simple Concurrency Scope
Section titled “Simple Concurrency Scope”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); }}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.eventSequences.AppendOptionsimport io.cratis.chronicle.eventSequences.EventSequenceNumberimport io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilderimport io.cratis.chronicle.events.EventType
@EventTypedata class ConcurrencyStockReserved(val sku: String = "", val quantity: Int = 0)
/** * Appends only if the event source is still at the expected sequence number — the kernel * rejects the append with a concurrency violation if another writer got there first. */suspend fun reserveStockIfUnchanged(store: IEventStore, sku: String, expectedSequenceNumber: Long) { val scope = ConcurrencyScopeBuilder() .withSequenceNumber(EventSequenceNumber(expectedSequenceNumber)) .withEventSourceId() .build()
val result = store.eventLog.append(sku, ConcurrencyStockReserved(sku, 1), AppendOptions(concurrencyScope = scope)) val violation = result.concurrencyViolation if (!result.isSuccess && violation != null) { println("Concurrency violation: expected ${violation.expectedSequenceNumber}, actual ${violation.actualSequenceNumber}") }}import io.cratis.chronicle.EventStore;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendOptions;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScope;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyViolation;
import io.cratis.chronicle.java.ConcurrencyScopeBuilderJavaBridge;import io.cratis.chronicle.java.EventLogJavaBridge;
@EventTyperecord ConcurrencyStockReserved(String sku, int quantity) {}
class EventsConcurrencyBasic { // Appends only if the event source is still at the expected sequence number — the kernel // rejects the append with a concurrency violation if another writer got there first. void reserveStockIfUnchanged(EventStore store, String sku, long expectedSequenceNumber) { ConcurrencyScope scope = ConcurrencyScopeBuilderJavaBridge .withSequenceNumber(new ConcurrencyScopeBuilder(), expectedSequenceNumber) .withEventSourceId() .build();
AppendResult result = EventLogJavaBridge.append( store.getEventLog(), sku, new ConcurrencyStockReserved(sku, 1), new AppendOptions(null, scope));
ConcurrencyViolation violation = result.getConcurrencyViolation(); if (!result.isSuccess() && violation != null) { System.out.println("Concurrency violation for event source: " + violation.getEventSourceId()); } }}defmodule MyApp.Events.ConcurrencyAccountOpened do use Chronicle.Events.EventType, id: "concurrency-account-opened"
defstruct [:account_name]end
defmodule MyApp.ConcurrencyBankAccountService do alias Chronicle.Events.ConcurrencyScope alias MyApp.Events.ConcurrencyAccountOpened
def open_account(account_id, account_name) do scope = ConcurrencyScope.for_event_source(42)
Chronicle.append(account_id, %ConcurrencyAccountOpened{account_name: account_name}, concurrency_scope: scope ) endendimport { eventType, IEventStore } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ConcurrencyAccountOpened { @field(String) readonly accountName: string;
constructor(accountName: string) { this.accountName = accountName; }}
class ConcurrencyBankAccountService { constructor(private readonly store: IEventStore) {}
async openAccount(accountId: string, accountName: string): Promise<void> { await this.store.eventLog.append(accountId, new ConcurrencyAccountOpened(accountName), { concurrencyScope: { sequenceNumber: 42n, eventSourceId: true } }); }}Using ConcurrencyScopeBuilder
Section titled “Using ConcurrencyScopeBuilder”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); }}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.eventSequences.AppendOptionsimport io.cratis.chronicle.eventSequences.EventSequenceNumberimport io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder
@EventTypedata class ConcurrencyMoneyDeposited(val amount: Double = 0.0)
/** * Uses [ConcurrencyScopeBuilder] to fluently narrow a concurrency scope to this account's own * event source id and a specific event stream type. */suspend fun processTransaction(store: IEventStore, accountId: String, amount: Double) { val concurrencyScope = ConcurrencyScopeBuilder() .withEventSourceId() .withSequenceNumber(EventSequenceNumber(15)) .withEventStreamType("Transactions") .build()
store.eventLog.append( accountId, ConcurrencyMoneyDeposited(amount), AppendOptions(eventStreamType = "Transactions", concurrencyScope = concurrencyScope) )}import io.cratis.chronicle.EventStore;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendOptions;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScope;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder;
import io.cratis.chronicle.java.AppendOptionsBuilder;import io.cratis.chronicle.java.ConcurrencyScopeBuilderJavaBridge;import io.cratis.chronicle.java.EventLogJavaBridge;
@EventTyperecord ConcurrencyMoneyDeposited(double amount) {}
class EventsConcurrencyBuilder { // Uses ConcurrencyScopeBuilder to fluently narrow a concurrency scope to this account's own // event source id and a specific event stream type. AppendResult processTransaction(EventStore store, String accountId, double amount) { ConcurrencyScope concurrencyScope = ConcurrencyScopeBuilderJavaBridge .withSequenceNumber(new ConcurrencyScopeBuilder(), 15) .withEventSourceId() .withEventStreamType("Transactions") .build();
AppendOptions options = new AppendOptionsBuilder() .eventStreamType("Transactions") .concurrencyScope(concurrencyScope) .build();
return EventLogJavaBridge.append(store.getEventLog(), accountId, new ConcurrencyMoneyDeposited(amount), options); }}defmodule MyApp.Events.ConcurrencyMoneyDeposited do use Chronicle.Events.EventType, id: "concurrency-money-deposited"
defstruct [:amount]end
defmodule MyApp.Events.ConcurrencyMoneyWithdrawn do use Chronicle.Events.EventType, id: "concurrency-money-withdrawn"
defstruct [:amount]end
defmodule MyApp.ConcurrencyAccountTransactionService do alias Chronicle.Events.ConcurrencyScope alias MyApp.Events.{ConcurrencyMoneyDeposited, ConcurrencyMoneyWithdrawn}
def process_transaction(account_id, amount) do # Pipe-able builder helpers compose a scope step by step. scope = 15 |> ConcurrencyScope.new() |> ConcurrencyScope.with_event_source() |> ConcurrencyScope.with_event_stream_type("Transactions") |> ConcurrencyScope.with_event_types([ConcurrencyMoneyDeposited, ConcurrencyMoneyWithdrawn])
Chronicle.append(account_id, %ConcurrencyMoneyDeposited{amount: amount}, concurrency_scope: scope ) endendimport { eventType, getEventTypeFor, IEventLog } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ConcurrencyMoneyDeposited { @field(Number) readonly amount: number;
constructor(amount: number) { this.amount = amount; }}
@eventType()class ConcurrencyMoneyWithdrawn { @field(Number) readonly amount: number;
constructor(amount: number) { this.amount = amount; }}
class ConcurrencyAccountTransactionService { constructor(private readonly eventLog: IEventLog) {}
async processTransaction(accountId: string, amount: number): Promise<void> { await this.eventLog.append(accountId, new ConcurrencyMoneyDeposited(amount), { concurrencyScope: { sequenceNumber: 15n, eventStreamType: 'Transactions', eventTypes: [getEventTypeFor(ConcurrencyMoneyDeposited), getEventTypeFor(ConcurrencyMoneyWithdrawn)] } }); }}Incomplete explicit scopes
Section titled “Incomplete explicit scopes”Caller-supplied scopes fail closed when they have narrowing but no expected position. A scope built with an unavailable tail, using either ConcurrencyScopeBuilder or new ConcurrencyScope, becomes a “no matching event may exist” expectation. This includes with copies and every append variant, including named tags and labelled batches.
| Supplied scope | Default behavior |
|---|---|
| Incomplete, with a source, stream, source type or nonempty event-type filter | Expect no matching event |
| Empty builder, empty event-type list or wildcard dimensions only | No check, with a client warning |
ConcurrencyScope.None | No check |
ConcurrencyScope.NotSet | Resolve through the configured strategy |
Strategy-issued scope, including a with copy | Keep the strategy’s first-append policy |
An empty event-type list means any type; it does not mean “the whole log must be empty”. Use ExpectingNoMatchingEvent() explicitly when that is the expectation you intend.
ConcurrencyOptions.SkipIncompleteExplicitScopes = true restores legacy skipping of incomplete explicit scopes. It is separate from CheckFirstAppendIntoAScope, which controls strategy-issued scopes. These correctness changes apply even when read protection is off.
Scoping by Event Metadata Tags
Section titled “Scoping by Event Metadata Tags”EventSourceType and EventStreamType
Section titled “EventSourceType and EventStreamType”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); }}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.eventSequences.AppendOptionsimport io.cratis.chronicle.eventSequences.EventSequenceNumberimport io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder
@EventTypedata class ConcurrencyAccountSettingsUpdated(val settings: String = "")
/** * Scopes concurrency to a specific event source type and event stream type, in addition to the * event source id. */suspend fun updateAccountSettings(store: IEventStore, accountId: String, settings: String) { val concurrencyScope = ConcurrencyScopeBuilder() .withEventSourceId() .withEventSourceType("BankAccount") .withEventStreamType("AccountManagement") .withSequenceNumber(EventSequenceNumber(10)) .build()
store.eventLog.append( accountId, ConcurrencyAccountSettingsUpdated(settings), AppendOptions(eventSourceType = "BankAccount", eventStreamType = "AccountManagement", concurrencyScope = concurrencyScope) )}import io.cratis.chronicle.EventStore;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendOptions;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScope;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder;
import io.cratis.chronicle.java.AppendOptionsBuilder;import io.cratis.chronicle.java.ConcurrencyScopeBuilderJavaBridge;import io.cratis.chronicle.java.EventLogJavaBridge;
@EventTyperecord ConcurrencyAccountSettingsUpdated(String settings) {}
class EventsConcurrencySourceAndStreamType { // Scopes concurrency to a specific event source type and event stream type, in addition to // the event source id. AppendResult updateAccountSettings(EventStore store, String accountId, String settings) { ConcurrencyScope concurrencyScope = ConcurrencyScopeBuilderJavaBridge .withSequenceNumber(new ConcurrencyScopeBuilder(), 10) .withEventSourceId() .withEventSourceType("BankAccount") .withEventStreamType("AccountManagement") .build();
AppendOptions options = new AppendOptionsBuilder() .eventSourceType("BankAccount") .eventStreamType("AccountManagement") .concurrencyScope(concurrencyScope) .build();
return EventLogJavaBridge.append(store.getEventLog(), accountId, new ConcurrencyAccountSettingsUpdated(settings), options); }}defmodule MyApp.Events.ConcurrencyAccountSettingsUpdated do use Chronicle.Events.EventType, id: "concurrency-account-settings-updated"
defstruct [:settings]end
defmodule MyApp.ConcurrencyAccountManagementService do alias Chronicle.Events.ConcurrencyScope alias MyApp.Events.ConcurrencyAccountSettingsUpdated
def update_account_settings(account_id, settings) do scope = ConcurrencyScope.for_event_source(10, event_source_type: "BankAccount", event_stream_type: "AccountManagement" )
Chronicle.append( account_id, %ConcurrencyAccountSettingsUpdated{settings: settings}, event_source_type: "BankAccount", event_stream_type: "AccountManagement", concurrency_scope: scope ) endendimport { eventType, IEventLog } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ConcurrencyAccountSettingsUpdated { @field(String) readonly settings: string;
constructor(settings: string) { this.settings = settings; }}
class ConcurrencyAccountManagementService { constructor(private readonly eventLog: IEventLog) {}
async updateAccountSettings(accountId: string, settings: string): Promise<void> { await this.eventLog.appendMany([{ eventSourceId: accountId, event: new ConcurrencyAccountSettingsUpdated(settings), eventSourceType: 'BankAccount', eventStreamType: 'AccountManagement' }], { concurrencyScope: { sequenceNumber: 10n, eventSourceType: 'BankAccount', eventStreamType: 'AccountManagement' } }); }}EventStreamId
Section titled “EventStreamId”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); }}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.eventSequences.AppendOptionsimport io.cratis.chronicle.eventSequences.EventSequenceNumberimport io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder
@EventTypedata class ConcurrencyMonthlyReportGenerated(val month: String = "")
/** * Scopes concurrency to a specific event stream id within a stream type, so reports for * different months don't contend with each other. */suspend fun generateMonthlyReport(store: IEventStore, accountId: String, monthKey: String) { val concurrencyScope = ConcurrencyScopeBuilder() .withEventSourceId() .withEventStreamType("Reporting") .withEventStreamId(monthKey) .withSequenceNumber(EventSequenceNumber(5)) .build()
store.eventLog.append( accountId, ConcurrencyMonthlyReportGenerated(monthKey), AppendOptions(eventStreamType = "Reporting", eventStreamId = monthKey, concurrencyScope = concurrencyScope) )}import io.cratis.chronicle.EventStore;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendOptions;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScope;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder;
import io.cratis.chronicle.java.AppendOptionsBuilder;import io.cratis.chronicle.java.ConcurrencyScopeBuilderJavaBridge;import io.cratis.chronicle.java.EventLogJavaBridge;
@EventTyperecord ConcurrencyMonthlyReportGenerated(String month) {}
class EventsConcurrencyStreamId { // Scopes concurrency to a specific event stream id within a stream type, so reports for // different months don't contend with each other. AppendResult generateMonthlyReport(EventStore store, String accountId, String monthKey) { ConcurrencyScope concurrencyScope = ConcurrencyScopeBuilderJavaBridge .withSequenceNumber(new ConcurrencyScopeBuilder(), 5) .withEventSourceId() .withEventStreamType("Reporting") .withEventStreamId(monthKey) .build();
AppendOptions options = new AppendOptionsBuilder() .eventStreamType("Reporting") .eventStreamId(monthKey) .concurrencyScope(concurrencyScope) .build();
return EventLogJavaBridge.append(store.getEventLog(), accountId, new ConcurrencyMonthlyReportGenerated(monthKey), options); }}defmodule MyApp.Events.ConcurrencyMonthlyReportGenerated do use Chronicle.Events.EventType, id: "concurrency-monthly-report-generated"
defstruct [:month]end
defmodule MyApp.ConcurrencyMonthlyReportService do alias Chronicle.Events.ConcurrencyScope alias MyApp.Events.ConcurrencyMonthlyReportGenerated
def generate_monthly_report(account_id, month_key) do scope = ConcurrencyScope.for_event_source(5, event_stream_type: "Reporting", event_stream_id: month_key )
Chronicle.append( account_id, %ConcurrencyMonthlyReportGenerated{month: month_key}, event_stream_type: "Reporting", event_stream_id: month_key, concurrency_scope: scope ) endendimport { eventType, IEventLog } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ConcurrencyMonthlyReportGenerated { @field(String) readonly month: string;
constructor(month: string) { this.month = month; }}
class ConcurrencyMonthlyReportService { constructor(private readonly eventLog: IEventLog) {}
async generateMonthlyReport(accountId: string, month: Date): Promise<void> { const monthKey = `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, '0')}`;
await this.eventLog.appendMany([{ eventSourceId: accountId, event: new ConcurrencyMonthlyReportGenerated(monthKey), eventStreamType: 'Reporting', eventStreamId: monthKey }], { concurrencyScope: { sequenceNumber: 5n, eventStreamType: 'Reporting', eventStreamId: monthKey } }); }}Event Types
Section titled “Event Types”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); }}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.events.EventTypeDescriptorimport io.cratis.chronicle.events.EventTypeIdimport io.cratis.chronicle.eventSequences.AppendOptionsimport io.cratis.chronicle.eventSequences.EventSequenceNumberimport io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder
@EventTypedata class ConcurrencyPaymentProcessed(val amount: Double = 0.0)
@EventTypedata class ConcurrencyPaymentFailed(val amount: Double = 0.0)
@EventTypedata class ConcurrencyPaymentRefunded(val amount: Double = 0.0)
/** * Narrows the concurrency scope to only the payment-related event types, so other event types * appended for the same account don't affect this check. */suspend fun processPayment(store: IEventStore, accountId: String, amount: Double) { val concurrencyScope = ConcurrencyScopeBuilder() .withEventSourceId() .withSequenceNumber(EventSequenceNumber(20)) .withEventType(EventTypeDescriptor(EventTypeId("ConcurrencyPaymentProcessed"))) .withEventType(EventTypeDescriptor(EventTypeId("ConcurrencyPaymentFailed"))) .withEventType(EventTypeDescriptor(EventTypeId("ConcurrencyPaymentRefunded"))) .build()
store.eventLog.append( accountId, ConcurrencyPaymentProcessed(amount), AppendOptions(concurrencyScope = concurrencyScope) )}import io.cratis.chronicle.EventStore;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.events.EventTypeDescriptor;import io.cratis.chronicle.eventSequences.AppendOptions;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScope;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder;
import io.cratis.chronicle.java.ConcurrencyScopeBuilderJavaBridge;import io.cratis.chronicle.java.EventLogJavaBridge;
@EventTyperecord ConcurrencyPaymentProcessed(double amount) {}
@EventTyperecord ConcurrencyPaymentFailed(double amount) {}
@EventTyperecord ConcurrencyPaymentRefunded(double amount) {}
class EventsConcurrencyEventTypes { /** * Narrows the concurrency scope to only the payment-related event types, so other event types * appended for the same account don't affect this check. */ void processPayment(EventStore store, String accountId, double amount) { ConcurrencyScope concurrencyScope = ConcurrencyScopeBuilderJavaBridge .withSequenceNumber(new ConcurrencyScopeBuilder(), 20) .withEventSourceId() .withEventType(EventTypeDescriptor.parse("ConcurrencyPaymentProcessed")) .withEventType(EventTypeDescriptor.parse("ConcurrencyPaymentFailed")) .withEventType(EventTypeDescriptor.parse("ConcurrencyPaymentRefunded")) .build();
EventLogJavaBridge.append( store.getEventLog(), accountId, new ConcurrencyPaymentProcessed(amount), new AppendOptions(null, concurrencyScope)); }}defmodule MyApp.Events.ConcurrencyPaymentProcessed do use Chronicle.Events.EventType, id: "concurrency-payment-processed"
defstruct [:amount]end
defmodule MyApp.Events.ConcurrencyPaymentFailed do use Chronicle.Events.EventType, id: "concurrency-payment-failed"
defstruct [:amount]end
defmodule MyApp.Events.ConcurrencyPaymentRefunded do use Chronicle.Events.EventType, id: "concurrency-payment-refunded"
defstruct [:amount]end
defmodule MyApp.ConcurrencyAccountService do alias Chronicle.Events.ConcurrencyScope alias MyApp.Events.{ConcurrencyPaymentFailed, ConcurrencyPaymentProcessed, ConcurrencyPaymentRefunded}
def process_payment(account_id, amount) do # Only check concurrency for payment-related events scope = ConcurrencyScope.for_event_source(20, event_types: [ConcurrencyPaymentProcessed, ConcurrencyPaymentFailed, ConcurrencyPaymentRefunded] )
Chronicle.append(account_id, %ConcurrencyPaymentProcessed{amount: amount}, concurrency_scope: scope ) endendimport { eventType, getEventTypeFor, IEventLog } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ConcurrencyPaymentProcessed { @field(Number) readonly amount: number;
constructor(amount: number) { this.amount = amount; }}
@eventType()class ConcurrencyPaymentFailed { @field(Number) readonly amount: number;
constructor(amount: number) { this.amount = amount; }}
@eventType()class ConcurrencyPaymentRefunded { @field(Number) readonly amount: number;
constructor(amount: number) { this.amount = amount; }}
class ConcurrencyAccountService { constructor(private readonly eventLog: IEventLog) {}
async processPayment(accountId: string, amount: number): Promise<void> { // Only check concurrency for payment-related events await this.eventLog.append(accountId, new ConcurrencyPaymentProcessed(amount), { concurrencyScope: { sequenceNumber: 20n, eventTypes: [ getEventTypeFor(ConcurrencyPaymentProcessed), getEventTypeFor(ConcurrencyPaymentFailed), getEventTypeFor(ConcurrencyPaymentRefunded) ] } }); }}AppendMany with Concurrency Scopes
Section titled “AppendMany with Concurrency Scopes”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); }}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.events.EventTypeDescriptorimport io.cratis.chronicle.events.EventTypeIdimport io.cratis.chronicle.eventSequences.AppendResultimport io.cratis.chronicle.eventSequences.EventForEventSourceIdimport io.cratis.chronicle.eventSequences.EventSequenceNumberimport io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder
@EventTypedata class ConcurrencyMoneyWithdrawnForTransfer(val amount: Double = 0.0)
@EventTypedata class ConcurrencyMoneyDepositedForTransfer(val amount: Double = 0.0)
/** * Appends to two event sources as one atomic batch, each checked against its own expected * sequence number and narrowed to the event type it produces. */suspend fun transferMoney(store: IEventStore, fromAccount: String, toAccount: String, amount: Double): List<AppendResult> { val events = listOf( EventForEventSourceId(fromAccount, ConcurrencyMoneyWithdrawnForTransfer(amount)), EventForEventSourceId(toAccount, ConcurrencyMoneyDepositedForTransfer(amount)) )
val concurrencyScopes = mapOf( fromAccount to ConcurrencyScopeBuilder() .withEventSourceId() .withSequenceNumber(EventSequenceNumber(50)) .withEventType(EventTypeDescriptor(EventTypeId("ConcurrencyMoneyWithdrawnForTransfer"))) .build(), toAccount to ConcurrencyScopeBuilder() .withEventSourceId() .withSequenceNumber(EventSequenceNumber(25)) .withEventType(EventTypeDescriptor(EventTypeId("ConcurrencyMoneyDepositedForTransfer"))) .build() )
return store.eventLog.appendMany(events, concurrencyScopes)}import io.cratis.chronicle.EventStore;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.eventSequences.EventForEventSourceId;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScope;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder;
import java.util.List;import java.util.Map;
import io.cratis.chronicle.java.ConcurrencyScopeBuilderJavaBridge;import io.cratis.chronicle.java.EventSequenceJavaBridge;
@EventTyperecord ConcurrencyMoneyWithdrawnForTransfer(double amount) {}
@EventTyperecord ConcurrencyMoneyDepositedForTransfer(double amount) {}
class EventsConcurrencyAppendMany { // Appends to two event sources as one atomic batch, each checked against its own expected // sequence number. List<AppendResult> transferMoney(EventStore store, String fromAccount, String toAccount, double amount) { List<EventForEventSourceId> events = List.of( new EventForEventSourceId(fromAccount, new ConcurrencyMoneyWithdrawnForTransfer(amount)), new EventForEventSourceId(toAccount, new ConcurrencyMoneyDepositedForTransfer(amount)));
ConcurrencyScope fromScope = ConcurrencyScopeBuilderJavaBridge .withSequenceNumber(new ConcurrencyScopeBuilder(), 50) .withEventSourceId() .build(); ConcurrencyScope toScope = ConcurrencyScopeBuilderJavaBridge .withSequenceNumber(new ConcurrencyScopeBuilder(), 25) .withEventSourceId() .build();
Map<String, ConcurrencyScope> concurrencyScopes = Map.of(fromAccount, fromScope, toAccount, toScope);
return EventSequenceJavaBridge.appendMany(store.getEventLog(), events, concurrencyScopes, null); }}defmodule MyApp.Events.ConcurrencyMoneyWithdrawnForTransfer do use Chronicle.Events.EventType, id: "concurrency-money-withdrawn-for-transfer"
defstruct [:amount]end
defmodule MyApp.Events.ConcurrencyMoneyDepositedForTransfer do use Chronicle.Events.EventType, id: "concurrency-money-deposited-for-transfer"
defstruct [:amount]end
defmodule MyApp.ConcurrencyTransferService do alias Chronicle.Events.ConcurrencyScope alias Chronicle.Transactions.UnitOfWork alias MyApp.Events.{ConcurrencyMoneyDepositedForTransfer, ConcurrencyMoneyWithdrawnForTransfer}
def transfer_money(from_account, to_account, amount) do unit_of_work = UnitOfWork.begin()
try do :ok = Chronicle.append( from_account, %ConcurrencyMoneyWithdrawnForTransfer{amount: amount}, concurrency_scope: ConcurrencyScope.for_event_source(50) )
:ok = Chronicle.append( to_account, %ConcurrencyMoneyDepositedForTransfer{amount: amount}, concurrency_scope: ConcurrencyScope.for_event_source(25) )
:ok = UnitOfWork.commit(unit_of_work) rescue exception -> UnitOfWork.rollback(unit_of_work) reraise exception, __STACKTRACE__ end endendimport { eventType, EventForEventSourceId, getEventTypeFor, IEventLog } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ConcurrencyMoneyWithdrawnForTransfer { @field(Number) readonly amount: number;
constructor(amount: number) { this.amount = amount; }}
@eventType()class ConcurrencyMoneyDepositedForTransfer { @field(Number) readonly amount: number;
constructor(amount: number) { this.amount = amount; }}
class ConcurrencyTransferService { constructor(private readonly eventLog: IEventLog) {}
async transferMoney(fromAccount: string, toAccount: string, amount: number): Promise<void> { const events: EventForEventSourceId[] = [ { eventSourceId: fromAccount, event: new ConcurrencyMoneyWithdrawnForTransfer(amount) }, { eventSourceId: toAccount, event: new ConcurrencyMoneyDepositedForTransfer(amount) } ];
await this.eventLog.appendMany(events, { concurrencyScopes: { [fromAccount]: { sequenceNumber: 50n, eventTypes: [getEventTypeFor(ConcurrencyMoneyWithdrawnForTransfer)] }, [toAccount]: { sequenceNumber: 25n, eventTypes: [getEventTypeFor(ConcurrencyMoneyDepositedForTransfer)] } } }); }}In .NET, the ordered-scope overload AppendMany(events, correlationId, tags, concurrencyScopes) also accepts several KeyValuePair<EventSourceId, ConcurrencyScope> entries with the same label and different predicates. Use this for a decision that reads several streams of one source, even if it appends only one event. Every predicate must hold; none is replaced by another. The kernel must advertise concurrency-scope-conjunctions, or the client refuses before sending the batch. A unit of work and an EventsWithConcurrencyScopes reactor result preserve the same conjunction. Use the result’s Entries property rather than its single-scope-per-label dictionary accessor.
A reported violation includes ScopeIndex, the zero-based position in the submitted scope list, and Scope, the failed predicate and expected position. Either may be absent with older kernels or single-scope RPCs.
Every scope in the batch is checked once, against the state of the event store before the batch. A scope never sees events that the same batch is about to write, so two events for one event source do not trip over each other. The batch is also all-or-nothing: if any scope reports a violation, Chronicle appends none of the events and returns every violation it found.
Event Source Operations with Concurrency
Section titled “Event Source Operations with Concurrency”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(); }}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.events.EventTypeDescriptorimport io.cratis.chronicle.events.EventTypeIdimport io.cratis.chronicle.eventSequences.EventSequenceNumberimport io.cratis.chronicle.eventSequences.operations.forEventSourceId
@EventTypeclass ConcurrencyAccountValidated
@EventTypeclass ConcurrencyAccountProcessed
/** * Composes two events against the same event source, with a shared concurrency scope narrowed * to the event types this operation produces. */suspend fun processAccountBatch(store: IEventStore, accountId: String) { store.eventLog .forEventSourceId(accountId) { withConcurrencyScope { withSequenceNumber(EventSequenceNumber(30)) withEventType(EventTypeDescriptor(EventTypeId("ConcurrencyAccountProcessed"))) withEventType(EventTypeDescriptor(EventTypeId("ConcurrencyAccountValidated"))) } append(ConcurrencyAccountValidated()) append(ConcurrencyAccountProcessed()) } .perform()}import io.cratis.chronicle.EventStore;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.eventSequences.operations.EventSequenceOperations;
import java.util.List;
import io.cratis.chronicle.java.ConcurrencyScopeBuilderJavaBridge;import io.cratis.chronicle.java.EventSequenceOperationsJavaBridge;import io.cratis.chronicle.java.EventSourceOperationsJavaBridge;
@EventTyperecord ConcurrencyAccountValidated() {}
@EventTyperecord ConcurrencyAccountProcessed() {}
class EventsConcurrencyForEventSourceOperations { // Composes two events against the same event source, with a shared concurrency scope. List<AppendResult> processAccountBatch(EventStore store, String accountId) { EventSequenceOperations operations = EventSequenceOperationsJavaBridge.operationsFor(store.getEventLog());
EventSequenceOperationsJavaBridge.forEventSourceId(operations, accountId, source -> { EventSourceOperationsJavaBridge.withConcurrencyScope( source, scope -> ConcurrencyScopeBuilderJavaBridge.withSequenceNumber(scope, 30).withEventSourceId()); EventSourceOperationsJavaBridge.append(source, new ConcurrencyAccountValidated()); EventSourceOperationsJavaBridge.append(source, new ConcurrencyAccountProcessed()); });
return EventSequenceOperationsJavaBridge.perform(operations); }}defmodule MyApp.Events.ConcurrencyAccountValidated do use Chronicle.Events.EventType, id: "concurrency-batch-account-validated"
defstruct []end
defmodule MyApp.Events.ConcurrencyAccountProcessed do use Chronicle.Events.EventType, id: "concurrency-batch-account-processed"
defstruct []end
defmodule MyApp.ConcurrencyBatchAccountProcessor do alias Chronicle.Events.ConcurrencyScope alias MyApp.Events.{ConcurrencyAccountProcessed, ConcurrencyAccountValidated}
def process_account_batch(account_id) do scope = ConcurrencyScope.for_event_source(30, event_types: [ConcurrencyAccountProcessed, ConcurrencyAccountValidated] )
Chronicle.append_many( account_id, [%ConcurrencyAccountValidated{}, %ConcurrencyAccountProcessed{}], concurrency_scope: scope ) endendimport { eventType, getEventTypeFor, IEventLog } from '@cratis/chronicle';
@eventType()class ConcurrencyAccountValidated {}
@eventType()class ConcurrencyAccountProcessed {}
class ConcurrencyBatchAccountProcessor { constructor(private readonly eventLog: IEventLog) {}
async processAccountBatch(accountId: string): Promise<void> { const results = await this.eventLog.appendMany(accountId, [ new ConcurrencyAccountValidated(), new ConcurrencyAccountProcessed() ], { concurrencyScope: { sequenceNumber: 30n, eventTypes: [ getEventTypeFor(ConcurrencyAccountProcessed), getEventTypeFor(ConcurrencyAccountValidated) ] } }); if (results.some(result => !result.isSuccess)) { throw new Error('Account batch was not appended'); } }}Handling Concurrency Violations
Section titled “Handling Concurrency Violations”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; }}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.eventSequences.AppendOptionsimport io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder
@EventTypedata class ConcurrencySafeAccountOpened(val accountName: String = "")
/** * Handles a concurrency violation reported on [io.cratis.chronicle.eventSequences.AppendResult.concurrencyViolation] * by reporting which event source lost the race. The scope expects that no event exists yet * for the account, so a second "open account" append fails with a violation. */suspend fun tryOpenAccount(store: IEventStore, accountId: String, accountName: String): Boolean { val concurrencyScope = ConcurrencyScopeBuilder() .withEventSourceId() .withExpectsNoMatchingEvent() .build()
val result = store.eventLog.append( accountId, ConcurrencySafeAccountOpened(accountName), AppendOptions(concurrencyScope = concurrencyScope) )
val violation = result.concurrencyViolation if (violation != null) { println("Concurrency violation for event source ${violation.eventSourceId}") return false }
return result.isSuccess}import io.cratis.chronicle.EventStore;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendOptions;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScope;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyViolation;
import io.cratis.chronicle.java.AppendOptionsBuilder;import io.cratis.chronicle.java.EventLogJavaBridge;
@EventTyperecord ConcurrencySafeAccountOpened(String accountName) {}
class EventsConcurrencyHandlingViolations { // Handles a concurrency violation reported on AppendResult.getConcurrencyViolation() - the // event source id it was reported for is enough to tell which account lost the race. boolean tryOpenAccount(EventStore store, String accountId, String accountName) { // Expect that no event exists yet for this account. ConcurrencyScope concurrencyScope = new ConcurrencyScopeBuilder() .withEventSourceId() .withExpectsNoMatchingEvent() .build();
AppendOptions options = new AppendOptionsBuilder().concurrencyScope(concurrencyScope).build(); AppendResult result = EventLogJavaBridge.append( store.getEventLog(), accountId, new ConcurrencySafeAccountOpened(accountName), options);
ConcurrencyViolation violation = result.getConcurrencyViolation(); if (violation != null) { System.out.println("Concurrency violation for event source " + violation.getEventSourceId()); return false; }
return result.isSuccess(); }}defmodule MyApp.Events.ConcurrencySafeAccountOpened do use Chronicle.Events.EventType, id: "concurrency-safe-account-opened"
defstruct account_name: ""end
defmodule MyApp.ConcurrencySafeAccountService do alias Chronicle.Events.ConcurrencyScope alias MyApp.Events.ConcurrencySafeAccountOpened
def try_open_account(account_id, account_name) do # Expect no event for this account yet scope = ConcurrencyScope.expecting_no_matching_event(event_source_id: true)
case Chronicle.append( account_id, %ConcurrencySafeAccountOpened{account_name: account_name}, concurrency_scope: scope ) do :ok -> true
{:error, {:concurrency_violations, _violations}} -> # Another append got there first — retry against the state the winner # produced, or surface the conflict. false
{:error, _reason} -> false end endendimport { eventType, EventSequenceNumber, IEventStore } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ConcurrencySafeAccountOpened { @field(String) readonly accountName: string;
constructor(accountName: string) { this.accountName = accountName; }}
class ConcurrencyViolationHandler { constructor(private readonly store: IEventStore) {}
async tryOpenAccount(accountId: string, accountName: string): Promise<boolean> { const result = await this.store.eventLog.append(accountId, new ConcurrencySafeAccountOpened(accountName), { concurrencyScope: { sequenceNumber: EventSequenceNumber.beforeFirst.value, eventSourceId: true } });
const violation = result.concurrencyViolation; if (violation) { console.log(`Expected sequence ${violation.expectedSequenceNumber.value}, actual was ${violation.actualSequenceNumber.value}`); return false; }
return result.isSuccess; }}Concurrency Strategies
Section titled “Concurrency Strategies”Chronicle provides built-in concurrency strategies:
Optimistic Concurrency Strategy
Section titled “Optimistic Concurrency Strategy”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 injectionpublic 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); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.ConcurrencyStrategyAccountNameChanged do use Chronicle.Events.EventType, id: "concurrency-strategy-account-name-changed"
defstruct [:new_name]end
defmodule MyApp.ConcurrencyOptimisticAccountService do alias Chronicle.Events.ConcurrencyScope alias MyApp.Events.ConcurrencyStrategyAccountNameChanged
def update_account(account_id, new_name) do # The optimistic strategy, as a one-off: read the current tail for this event source # and expect nothing to have been appended since. `at_tail/2` reads and builds the scope # in one call. {:ok, scope} = ConcurrencyScope.at_tail(account_id)
Chronicle.append( account_id, %ConcurrencyStrategyAccountNameChanged{new_name: new_name}, concurrency_scope: scope ) endendTo apply the optimistic strategy to every append on a client by default instead of building the
scope per call, configure Chronicle.Client:
{Chronicle.Client, connection_string: "chronicle://localhost:35000", event_store: "my-store", concurrency: [default: :optimistic]}An append that already carries an explicit or definition-derived scope is left alone; the default strategy only fills in the ones that have neither.
TypeScript does not support this workflow yet.Note:
[EventStreamType]on a reactor or reducer scopes which events an observer receives (see Filter by event stream type) — it does not set theEventStreamTypeused for concurrency scoping on an append call. SeteventStreamTypeexplicitly onAppend/AppendMany, or throughConcurrencyScopeBuilder.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”This section describes strategy-issued scopes. Caller-supplied scopes follow Incomplete explicit scopes.
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.
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, orEventStreamIdis 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.
Checking It — Opt In
Section titled “Checking It — Opt In”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; }}import io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.eventSequences.AppendOptionsimport io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder
@EventTypedata class ConcurrencyFirstAccountOpened(val accountName: String = "")
/** * Opts in to the first-append check with [ConcurrencyScopeBuilder.withExpectsNoMatchingEvent]. * The kernel rejects the append with a concurrency violation if an event already exists for * this event source, so two concurrent "open account" appends cannot both succeed. */suspend fun openAccountOnce(store: IEventStore, accountId: String, accountName: String): Boolean { val concurrencyScope = ConcurrencyScopeBuilder() .withEventSourceId() .withExpectsNoMatchingEvent() .build()
val result = store.eventLog.append( accountId, ConcurrencyFirstAccountOpened(accountName), AppendOptions(concurrencyScope = concurrencyScope) )
return result.isSuccess}import io.cratis.chronicle.EventStore;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.eventSequences.AppendOptions;import io.cratis.chronicle.eventSequences.AppendResult;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScope;import io.cratis.chronicle.eventSequences.concurrency.ConcurrencyScopeBuilder;
import io.cratis.chronicle.java.EventLogJavaBridge;
@EventTyperecord ConcurrencyFirstAccountOpened(String accountName) {}
class EventsConcurrencyFirstAppendCheck { // Opts in to the first-append check with withExpectsNoMatchingEvent(). The kernel rejects the // append with a concurrency violation if an event already exists for this event source, so // two concurrent "open account" appends cannot both succeed. boolean openAccountOnce(EventStore store, String accountId, String accountName) { ConcurrencyScope scope = new ConcurrencyScopeBuilder() .withEventSourceId() .withExpectsNoMatchingEvent() .build();
AppendResult result = EventLogJavaBridge.append( store.getEventLog(), accountId, new ConcurrencyFirstAccountOpened(accountName), new AppendOptions(null, scope));
return result.isSuccess(); }}defmodule MyApp.Events.ConcurrencyFirstAppendAccountOpened do use Chronicle.Events.EventType, id: "concurrency-first-append-account-opened"
defstruct account_name: ""end
defmodule MyApp.ConcurrencyFirstAppendAccountService do alias Chronicle.Events.ConcurrencyScope alias MyApp.Events.ConcurrencyFirstAppendAccountOpened
# Per append: ask for the check on one behavior without changing the client's default. def open_account(account_id, account_name) do scope = ConcurrencyScope.expecting_no_matching_event( event_source_id: true, event_types: [ConcurrencyFirstAppendAccountOpened] )
Chronicle.append( account_id, %ConcurrencyFirstAppendAccountOpened{account_name: account_name}, concurrency_scope: scope ) endendApplication-wide, opt every first append into an empty narrowing into the same check through
the client’s :concurrency option:
{Chronicle.Client, connection_string: "chronicle://localhost:35000", event_store: "my-store", concurrency: [default: :optimistic, check_first_append: true]}import { eventType, EventSequenceNumber, IEventStore } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ConcurrencyFirstAccountOpened { @field(String) readonly accountName: string;
constructor(accountName: string) { this.accountName = accountName; }}
class ConcurrencyFirstAppendService { constructor(private readonly store: IEventStore) {}
async openAccount(accountId: string, accountName: string): Promise<void> { // EventSequenceNumber.beforeFirst expects that no event matching the scope exists yet. // The client sends it as the kernel's ExpectsNoMatchingEvent condition. const result = await this.store.eventLog.append(accountId, new ConcurrencyFirstAccountOpened(accountName), { concurrencyScope: { sequenceNumber: EventSequenceNumber.beforeFirst.value, eventSourceId: true } }); if (!result.isSuccess) { throw new Error('The account already has events'); } }}- Application-wide —
ConcurrencyOptions.CheckFirstAppendIntoAScope. Every scope the optimistic strategy resolves gets the check. - Per append —
ConcurrencyScopeBuilder.ExpectingNoMatchingEvent(). One behavior asks for the check without changing the default for everything else.
Expressing “This Must Not Exist Yet”
Section titled “Expressing “This Must Not Exist Yet””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”. That makes “only one writer may open this partition” expressible as a concurrency concern, and it gives you something more useful: proof that an identifier you just generated is new.
Why a generated identifier needs proof
Section titled “Why a generated identifier needs proof”Picture a small shop. Customers get a CustomerId (cus_ plus a ULID). Orders get an OrderId, a TypeID with a time part and no tenant. Invitations get an InviteCode, a short NanoId. Support staff issue an ApiKey: 32 random bytes that are a secret. The key itself never becomes an identifier. Its stream is identified by a separate, non-sensitive ApiKeyId. The CustomerId, OrderId, InviteCode and ApiKeyId are minted by your code, and each one becomes the event source of a new stream.
A well-chosen generator makes a collision astronomically unlikely, but “unlikely” is a property of the generator, not of your event store. Nothing stops the same value from being produced twice by a bug, a replayed request, a seeded test generator or a clock that stepped back. If it happens, the second “new” stream silently appends onto the first, and two customers share one history.
A BeforeFirst scope closes that gap. The append says: “this identifier is new, and if it is not, refuse to write”. Chronicle checks it for you, and the answer comes from the event store rather than from your generator.
Place an order
Section titled “Place an order”Placing an order writes OrderPlaced and one OrderLineAdded per line, all for a brand-new OrderId. The scope asks that no event exists for that OrderId yet:
using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.EventSequences.Concurrency;
namespace FreshIdentifierExample{ public record OrderId(string Value) : EventSourceId<string>(Value);
[EventType] public record OrderPlaced(string CustomerId);
[EventType] public record OrderLineAdded(string Sku, int Quantity);
public record OrderLine(string Sku, int Quantity);
public class PlaceOrderService(IEventLog eventLog) { const int MaxAttempts = 3;
// newOrderId mints a generated identifier, for example a TypeID such as order_01h455vb4pex5vsknk084sn02q. public async Task<OrderId?> PlaceOrder(string customerId, IEnumerable<OrderLine> lines, Func<OrderId> newOrderId) { for (var attempt = 0; attempt < MaxAttempts; attempt++) { var orderId = newOrderId();
// "No event may exist for this OrderId yet" - the proof that the generated identifier is new. var mustBeNew = new ConcurrencyScopeBuilder() .ExpectingNoMatchingEvent() .WithEventSourceId(orderId) .Build();
var events = new List<object> { new OrderPlaced(customerId) }; events.AddRange(lines.Select(line => new OrderLineAdded(line.Sku, line.Quantity)));
var result = await eventLog.AppendMany(orderId, events, concurrencyScope: mustBeNew);
if (result.IsSuccess) { return orderId; }
if (!result.HasConcurrencyViolations) { throw new InvalidOperationException("The order could not be placed."); }
// The identifier was taken. Nothing was appended, so mint a new one and try again. }
return null; } }}Chronicle checks the scope once against the state before the batch, so the OrderLineAdded events do not count against the OrderPlaced event that arrives with them. Then it appends the whole batch as one operation. The scope names no event types, so any event for that OrderId is a collision; narrow it with WithEventType if only certain events should count.
Arc’s generated values ([GeneratedValue]) mint identifiers inside a command handler. Arc does not prove a generated value is absent from your storage, so a handler that appends for a generated identifier is where this scope belongs.
When the identifier is taken
Section titled “When the identifier is taken”If an event already exists for the identifier, or another append for the same identifier lands between your check and your write, the result carries a ConcurrencyViolation and nothing is appended. IsSuccess is false and HasConcurrencyViolations is true, as shown in Handling Concurrency Violations.
Chronicle numbers events as it writes them, and a storage write can collide with another writer that took the same sequence number. Chronicle then renumbers the append and tries again. Since version 19.40.1 it checks your scope again on every renumbered attempt, so a competing write that created the stream during the collision is reported as a violation instead of being written on top. Older kernels renumbered without checking again.
The fix on your side is short: generate a new identifier and append again, as the example does. Do not retry with the same identifier. The scope is telling you the stream is not yours.
Why low-entropy identifiers depend on it
Section titled “Why low-entropy identifiers depend on it”A ULID or a TypeID carries enough randomness that you will probably never see a collision. An InviteCode does not: a NanoId of six or eight characters is meant to be typed by a person, and at that size collisions are a matter of when. For identifiers like that, the freshness check is how you find out, and the retry loop is part of the design rather than an edge case.
The ApiKey shows the limit of this check. Freshness applies to the stream’s identifier, here the ApiKeyId, and says nothing about keeping a value private. Never use a secret as an event source id. Event source ids, like the rest of event metadata, are stored in plaintext and cannot be encrypted, and marking one [PII] is rejected. Keep the raw key out of events entirely and record only a fingerprint such as a hash of it, or, when you must store the secret itself, mark the payload property [Encrypted] (see encrypted values). Don’t use [PII] for a secret: it is for personal data and enrolls the value in erasure. Personal data in events explains the surrogate-identifier pattern. Arc’s [NotAudited] only keeps a value out of a command’s causation; it does not protect the value anywhere else.
Choosing a guard
Section titled “Choosing a guard”| What you need | Reach for |
|---|---|
| Prove a generated identifier is not already in use, at the moment you append | A BeforeFirst scope with ExpectingNoMatchingEvent() |
| Every writer, forever, may produce at most one event of a type for a source | A unique event type constraint |
| The identifier is a natural key from outside, such as an email address or a national number | A unique constraint on that value, or a deterministic event source id derived from it |
| Extending an existing stream without overwriting a concurrent change | A sequence-number scope from the optimistic strategy |
When not to use it
Section titled “When not to use it”- The identifier is a natural key that is already unique. Do not generate a value just to prove it is new. Derive the event source id from the key, so a second use lands on the same stream by design, or declare a unique constraint so the event store refuses duplicates for every writer.
- The rule has to hold for writers that never declared a scope. A scope only protects the appends that carry it. A constraint is enforced by the kernel for everyone.
- You want a retry to succeed after an error. A write can commit and still report an error, for example when a connection drops after the event store accepted it. Retrying with the same identifier then reports a violation, because the stream now exists: your own earlier write. Make the retry idempotent: when you retry after an error, first check whether the stream you attempted to create already holds your own events before you generate a new identifier.
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;
- an incomplete explicit scope has no narrowing, or
SkipIncompleteExplicitScopesrestores legacy skipping. A narrowed explicit scope otherwise expects no matching event.
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.
Mixed-Version Deployments
Section titled “Mixed-Version Deployments”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:
| Client | Kernel | First append into a narrowed scope, with the check on |
|---|---|---|
| current | current | Checked. Rejected if a matching event arrived in between |
| current | older | Skipped, 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 |
| older | current | Skipped, 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.
Revisions and redactions are outside append conditions
Section titled “Revisions and redactions are outside append conditions”Append conditions detect matching appended events after a boundary. Revisions and redactions rewrite existing history without advancing that append boundary. They are not detected by concurrency scopes, decision reads, read protection or constraints.
For example, a decision reads through position 100 and sees a credit of 100 at position 80. An administrator revises that credit to 0. A withdrawal appended at 101 can still pass its condition: the revision did not append a matching event above 100. A fresh fold can see the revised value, but a decision that already read it has no mutation guard.
Read protection has the same limit. Coordinate history mutations operationally: pause affected command producers, drain in-flight decisions, perform the revision or redaction, then resume with fresh reads. Quiesce affected commands during projection-definition deployments too. Retrying a stale decision or adding more scopes does not close this gap. An atomic mutation-epoch check is not part of the current contract.
Best Practices
Section titled “Best Practices”- 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
- Event type scoping: When possible, scope to specific event types to allow concurrent operations on different event types
- Handle violations gracefully: Always check for concurrency violations and implement appropriate retry or fallback logic
- Use builders for complex scopes: The
ConcurrencyScopeBuilderprovides a clear, fluent API for complex concurrency requirements - Use concurrency scope strategies: Inject
IConcurrencyScopeStrategiesto get a built-in strategy (such as optimistic concurrency) instead of hand-rolling sequence-number lookups - Assert the check ran: Read
IAppendResult.ConcurrencyCheckPerformedwhen a behavior depends on serialization — a skipped check looks exactly like a passing one - 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
- Capture the position before you read: A scope protects a decision only if its position was taken before the facts were read. Read the facts from the event log or a fresh fold, not from a materialized read model that may lag: otherwise the captured position covers events the decision never saw. See Choosing how a rule holds and the editing conflict scenario