Skip to content

Concurrency

Two librarians open the same book and both mark it as lent. Without a check, both commands succeed and the book is lent twice. Optimistic concurrency makes the second append fail: Chronicle compares the stream’s current tail with the tail the command expected, and rejects the append when they differ.

The integration gives you three ways to state that expectation. Choose by how the command decides.

The command decides fromUse
Nothing it read; it only needs no one else to write in betweenA routing decorator with { concurrency: true }
An empty stream, such as “create once”An exact scope with EventSequenceNumber.beforeFirst
The event source’s historyAn aggregate, which carries the revision it replayed

For a rule across event sources, such as a unique author name, use a Chronicle constraint instead. The Library sample registers UniqueAuthorName beside its command.

import { field } from '@cratis/fundamentals';
import { eventType } from '@cratis/chronicle/events';
import { command, key } from '@cratis/arc.core';
import { eventSourceType } from '@cratis/arc.chronicle';
@eventType()
export class BookLent {
@field(String) borrower: string;
constructor(borrower = '') { this.borrower = borrower; }
}
@command()
@eventSourceType('Book', { concurrency: true })
export class LendBook {
@field(String) @key() bookId = '';
@field(String) borrower = '';
handle(): BookLent { return new BookLent(this.borrower); }
}

{ concurrency: true } works the same on @eventSourceType, @eventStreamType, and @eventStreamId. When the command returns its events, the integration reads the tail of each event source in the batch, narrowed to the dimensions you marked, and sends that tail as the expected revision. A write that lands between that read and the append is rejected.

The tail is read after handle() has run. A write that landed while handle() was deciding is already part of that tail, so this check does not protect a decision made from state read earlier. For a read-modify-write rule, carry the revision you read, as the next two sections do.

import { field } from '@cratis/fundamentals';
import { EventSequenceNumber } from '@cratis/chronicle/eventSequences';
import { command, key } from '@cratis/arc.core';
import { eventsWithConcurrencyScopes } from '@cratis/arc.chronicle';
@command()
export class CreateLiveExactlyOnce {
@field(String) @key() id = '';
@field(String) name = '';
handle() {
return eventsWithConcurrencyScopes([new LiveCreated(this.name)], {
[this.id]: { eventSourceId: true, sequenceNumber: EventSequenceNumber.beforeFirst.value }
});
}
}

This excerpt is from the kernel suite, where LiveCreated is an @eventType() class. eventsWithConcurrencyScopes(events, scopes) sends each scope exactly as you wrote it. EventSequenceNumber.beforeFirst.value is the expected tail of a stream with no events, so the command succeeds once per ID; the second call is rejected.

Each scope is keyed by an event source ID and accepts:

FieldMeaning
sequenceNumberThe expected tail, a bigint
eventSourceIdtrue to scope to that event source
eventSourceType, eventStreamType, eventStreamIdNarrow the scope to that source type or stream
eventTypesNarrow the scope to these event types

A scope may name an event source the batch does not append to, which lets a decision about one source guard against changes to another. An exact scope replaces the routing-decorator check for the same source. The kernel requires at least one event in a batch, so a batch with scopes and no events fails.

An aggregate records the tail of its route when it is loaded, and the events it applies are appended with that tail as an exact scope. A concurrent append on the same route between load and commit rejects the batch. See Aggregates.

A concurrency or constraint rejection becomes a validation result. The caller gets 400, the command result carries no response, and nothing from the batch is appended.

RejectionreasonmessageOther fields
ConcurrencyconcurrencyViolationConcurrent modification prevented the appendstate with eventSourceId, expectedEventSequenceNumber, and actualEventSequenceNumber
ConstraintconstraintViolationThe constraint’s messagemembers holds the constrained property in camel case when Chronicle reports one; reasonDetail holds the constraint ID

Both have error severity. A client can show the message or retry the command with fresh state.

An unknown, incomplete, contradictory, or partial acknowledgment from Chronicle is not a rejection. The command fails with an exception, because Arc cannot tell what was stored.

Nested commands that share a batch may each add scopes. Two different scopes for the same event source fail the command instead of silently keeping one.

The in-memory test scenario accepts scopes but does not enforce them. Only a kernel does.