Appending many
Appending many events in a single call is useful for batch workflows such as imports, migrations, or multi-step business operations.
Batch appends preserve ordering and reduce per-event overhead compared to appending each event individually.
AppendMany is transactional all the way to storage. Either all events in the batch are committed, or none are. Chronicle assigns sequence numbers in order, persists the events and metadata atomically, and updates the sequence state once the batch succeeds.
How it works
Section titled “How it works”- Each item in the batch pairs an event with the event source it belongs to.
- In the .NET client, each
EventForEventSourceIdcan supply its ownCausation. The current ambient causation chain is kept first, followed by that event’s explicit cause. Events without an explicit cause keep only the ambient chain; they do not inherit another event’s cause. - Per-event causation requires an updated Chronicle kernel. Older kernels ignore the per-event field and store only the batch’s ambient causation chain.
- The batch is validated as a unit and written atomically.
- Sequence numbers are assigned in the order provided.
- The event sequence state is updated once the batch commits.
Appending a batch once
Section titled “Appending a batch once”AppendManyOnce appends events for one event source with a single IdempotencyKey. Chronicle derives a different key for each event from the batch key and the event’s zero-based position (IdempotencyKey.ForIndex), so the batch is keyed per event. Retrying the same batch with the same key and the same events in the same order writes nothing new. There is also AppendManyOnceWithNamedTags. See Appending for keys, outcomes, and conflicts.
using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.Events;
[EventType]public record ImportedItemAdded(string Sku, int Quantity);
public class CartImportService(IEventLog eventLog){ public async Task<bool> Import(string cartId, IEnumerable<ImportedItemAdded> items, string importId) { // One key for the batch. Chronicle derives a distinct key for each event from its position. var result = await eventLog.AppendManyOnce( cartId, items.Cast<object>(), new IdempotencyKey($"import:{importId}"));
if (result.HasIdempotencyConflicts) { return false; }
// Outcomes has one entry per event, in the order the events were given. return result.Outcomes.All(outcome => outcome == AppendOutcome.Appended); }}AppendManyResult.Outcomes has one AppendOutcome per event, in the order you gave them. IdempotencyConflicts and HasIdempotencyConflicts report refused keys. Because a batch is atomic, check them before assuming the batch was written. WasAlreadyAppended is true when every event was already in the sequence.
Keys on individual events
Section titled “Keys on individual events”When a batch spans event sources, set IdempotencyKey on each EventForEventSourceId instead. If any event in the batch has a key, the batch is appended once. Events without a key are appended normally, so a mixed batch is valid and Outcomes reports Appended for them.
using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.Events;
public readonly record struct KeyedAccountId(string Value){ public static implicit operator EventSourceId(KeyedAccountId id) => new(id.Value);}
[EventType]public record KeyedMoneyWithdrawn(decimal Amount);
[EventType]public record KeyedMoneyDeposited(decimal Amount);
public class KeyedTransferService(IEventLog eventLog){ public Task<AppendManyResult> Transfer(string transferId, KeyedAccountId fromAccount, KeyedAccountId toAccount, decimal amount) { var events = new[] { new EventForEventSourceId(fromAccount, new KeyedMoneyWithdrawn(amount)) { IdempotencyKey = new IdempotencyKey($"transfer:{transferId}:withdrawn") }, new EventForEventSourceId(toAccount, new KeyedMoneyDeposited(amount)) { IdempotencyKey = new IdempotencyKey($"transfer:{transferId}:deposited") } };
// Any keyed event makes the batch keyed; events without a key are appended normally. return eventLog.AppendMany(events); }}Keyed batches follow the same rules as a single keyed append: an older kernel throws IdempotentAppendsNotSupported, and keys are refused inside a unit of work and with effect receipts.
When to use
Section titled “When to use”Use this approach when you already have a set of events that should be appended together. Each event can still target different event sources, and you can include concurrency scopes to validate the boundary across streams. See Concurrency for details.
Example
Section titled “Example”using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.Events;
public readonly record struct AccountId(string Value){ public static implicit operator EventSourceId(AccountId id) => new(id.Value);}
[EventType]public record MoneyWithdrawn(decimal Amount);
[EventType]public record MoneyDeposited(decimal Amount);
public class TransferService(IEventLog eventLog){ public Task<AppendManyResult> Transfer(AccountId fromAccount, AccountId toAccount, decimal amount) { var events = new[] { new EventForEventSourceId(fromAccount, new MoneyWithdrawn(amount)), new EventForEventSourceId(toAccount, new MoneyDeposited(amount)) };
return eventLog.AppendMany(events); }}import io.cratis.chronicle.eventSequences.AppendResultimport io.cratis.chronicle.eventSequences.EventForEventSourceIdimport io.cratis.chronicle.eventSequences.IEventLogimport io.cratis.chronicle.events.EventType
@EventTypedata class MoneyWithdrawn(val amount: Double)
@EventTypedata class MoneyDeposited(val amount: Double)
class Transfers(private val eventLog: IEventLog) { /** * Moves money between two accounts as one atomic append - each event targets its own account, * and either both are committed or neither of them is. */ suspend fun transfer(fromAccount: String, toAccount: String, amount: Double): List<AppendResult> = eventLog.appendMany( listOf( EventForEventSourceId(fromAccount, MoneyWithdrawn(amount)), EventForEventSourceId(toAccount, MoneyDeposited(amount)) ) )}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.java.EventSequenceJavaBridge;
import java.util.List;
@EventTyperecord TransferMoneyWithdrawn(double amount) {}
@EventTyperecord TransferMoneyDeposited(double amount) {}
class EventsAppendingManyTransfer { // Moves money between two accounts as one atomic append — each event targets its own account, // and either both are committed or neither of them is. List<AppendResult> transfer(EventStore store, String fromAccount, String toAccount, double amount) { return EventSequenceJavaBridge.appendMany( store.getEventLog(), List.of( new EventForEventSourceId(fromAccount, new TransferMoneyWithdrawn(amount)), new EventForEventSourceId(toAccount, new TransferMoneyDeposited(amount)))); }}defmodule MyApp.Events.TransferMoneyWithdrawn do use Chronicle.Events.EventType, id: "transfer-money-withdrawn-v1"
defstruct [:amount]end
defmodule MyApp.Events.TransferMoneyDeposited do use Chronicle.Events.EventType, id: "transfer-money-deposited-v1"
defstruct [:amount]end
defmodule MyApp.TransferService do alias Chronicle.EventSequences.{EventForEventSourceId, EventLog} alias MyApp.Events.{TransferMoneyDeposited, TransferMoneyWithdrawn}
# Appends both events atomically in a single append-many, each carrying # its own target event source id. def transfer(from_account, to_account, amount) do events = [ %EventForEventSourceId{ event_source_id: from_account, event: %TransferMoneyWithdrawn{amount: amount} }, %EventForEventSourceId{ event_source_id: to_account, event: %TransferMoneyDeposited{amount: amount} } ]
EventLog.append_many_for_event_sources(events) endendimport { eventType, EventForEventSourceId, IEventStore } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class MoneyWithdrawn { @field(Number) readonly amount: number;
constructor(amount: number) { this.amount = amount; }}
@eventType()class MoneyDeposited { @field(Number) readonly amount: number;
constructor(amount: number) { this.amount = amount; }}
class TransferService { constructor(private readonly store: IEventStore) {}
async transfer(fromAccount: string, toAccount: string, amount: number): Promise<void> { const events: EventForEventSourceId[] = [ { eventSourceId: fromAccount, event: new MoneyWithdrawn(amount) }, { eventSourceId: toAccount, event: new MoneyDeposited(amount) } ];
const results = await this.store.eventLog.appendMany(events);
if (results.some(_ => !_.isSuccess)) { // Decide whether to retry or surface a conflict to the caller. } }}