Event Sequences
Event sequences are a shared Chronicle concept. Use the shared docs for the event log, custom sequences, filtering, and appending behavior.
The sections below cover Chronicle.EventSequences.EventLog APIs that are new to the
Elixir client and not yet covered by the shared docs above.
Redacting events
Section titled “Redacting events”Redaction permanently erases an event’s content, for example to honor a GDPR erasure
request. It works on events, not on keys: compliance encrypts
PII and makes it unreadable by deleting a subject’s key, while redaction overwrites the
stored event itself. Neither can be undone. Mirrors the C# and TypeScript clients’ IEventSequence.Redact().
Redact a single event by its sequence number, with a reason:
:ok = Chronicle.EventSequences.EventLog.redact(sequence_number, "Customer requested erasure")Redact every event for an event source in one call — for example, to erase all data for a specific user:
:ok = Chronicle.EventSequences.EventLog.redact_for_event_source( event_source_id, "Account closed under GDPR" )Narrow a bulk redaction to specific event types by passing a list of event type modules as the third argument:
:ok = Chronicle.EventSequences.EventLog.redact_for_event_source( event_source_id, "Email address was captured in error", [MyApp.Events.EmailChanged] )Both functions accept :client, :namespace and :event_sequence_id, plus the
:correlation_id, :identity and :causation context overrides described in
Context management. They ignore the other append/3 options, such as
:tags, :subject and :concurrency_scope.
Reading forward from a sequence number
Section titled “Reading forward from a sequence number”get_from_sequence_number/2 returns events from (and including) a given sequence number
onward, optionally filtered by event source id and event types. Mirrors the C# and
TypeScript clients’ GetFromSequenceNumber().
{:ok, events} = Chronicle.EventSequences.EventLog.get_from_sequence_number(sequence_number, event_source_id: event_source_id, event_types: [MyApp.Events.OrderPlaced] )The list holds the generated contract structs,
%Cratis.Chronicle.Contracts.Sequences.AppendedEventResponse{}, not your event structs.
Each carries its metadata in Context and the event’s JSON in its content field.
Sequence numbers
Section titled “Sequence numbers”get_tail_sequence_number/2 returns the sequence number of the last appended event.
get_next_sequence_number/2 mirrors it but returns the number that will be assigned to the
next appended event — it distinguishes “the log is empty” (returns 0) from “the tail
is at sequence 0” (returns 1), which get_tail_sequence_number/2 alone cannot do.
Mirrors the C# and TypeScript clients’ GetNextSequenceNumber().
{:ok, tail} = Chronicle.EventSequences.EventLog.get_tail_sequence_number(event_source_id){:ok, next} = Chronicle.EventSequences.EventLog.get_next_sequence_number(event_source_id)Leaving :event_source_type and :event_stream_type out looks across every source and stream type. Version 3.5.0 narrowed an omitted value to "Default" and returned {:ok, 0} however many events were stored; upgrade to 3.5.1 or later.
get_tail_sequence_number_for_observer/2 scopes the tail lookup to only the event types a
reactor or reducer module subscribes to (its @handles declarations), instead of every
event type. Mirrors the C# client’s GetTailSequenceNumberForObserver().
{:ok, tail} = Chronicle.EventSequences.EventLog.get_tail_sequence_number_for_observer( MyApp.Reactors.OrderNotifier )Filtering by stream
Section titled “Filtering by stream”get_for_event_source/2 and get_tail_sequence_number/2 also accept :event_source_type,
:event_stream_type, and :event_stream_id options, narrowing either call to a specific
non-default stream instead of silently ignoring these dimensions:
{:ok, events} = Chronicle.EventSequences.EventLog.get_for_event_source(event_source_id, event_stream_type: "audit-trail", event_stream_id: "2024" )Completing a stream
Section titled “Completing a stream”complete_stream/3 closes a named, non-default stream so that no further events can be
appended to it. The default stream can never be completed.
case Chronicle.EventSequences.EventLog.complete_stream("audit-trail", "2024") do {:ok, _tail_sequence_number} -> :ok {:error, :default_stream_cannot_be_completed} -> :error {:error, :already_completed} -> :ok {:error, reason} -> {:error, reason}endThe last clause covers transport errors such as {:error, :not_connected}.
Appending and waiting for observer completion
Section titled “Appending and waiting for observer completion”append_and_wait_for_completion/3 appends a single event and then waits (default: 5
seconds) for every observer affected by the append — reactors, reducers, projections — to
either reach the appended sequence number or fail, instead of returning as soon as the
event is durably stored. Mirrors the C# client’s AppendResult.WaitForCompletion().
case Chronicle.EventSequences.EventLog.append_and_wait_for_completion( event_source_id, %MyApp.Events.OrderPlaced{customer_id: customer_id, total: total} ) do {:ok, %{success: true, failed_partitions: []}} -> :ok
{:ok, %{success: false, failed_partitions: _failed_partitions}} -> # One or more observers failed on the event — inspect failed_partitions # (a list of `Chronicle.FailedPartitions.FailedPartition`). :error
{:error, reason} -> # The append failed, or it succeeded and the wait failed. See below. {:error, reason}endPass :timeout (milliseconds) to override the default wait.
Read the three outcomes carefully, because an error doesn’t always mean the event wasn’t stored:
{:ok, %{success: true}}: the event is stored and every affected observer has processed it.{:ok, %{success: false, failed_partitions: ...}}: the event is stored, but at least one observer failed on it.{:error, reason}: either the append failed, or the append succeeded and the wait itself failed. A wait that runs past the gRPC deadline returns{:error, %GRPC.RPCError{status: 4, message: "Deadline Exceeded"}}even though the event is stored. Don’t retry the append blindly on{:error, _}; check the event log or use a concurrency scope so a retry can’t store the event twice.
There is no append-many variant: waiting after append_many/3 isn’t supported.
Append results
Section titled “Append results”append/3 and append_many/3 return :ok or {:error, reason}. When you need the sequence
number that was assigned, or the structured reason an append was refused, use the _with_result
variants. They return a Chronicle.EventSequences.AppendResult; mirrors the .NET AppendResult
and AppendManyResult.
case Chronicle.append_with_result("user-1", %MyApp.Events.UserRegistered{email: "a@b.c"}) do {:ok, %{sequence_number: sequence_number}} -> {:registered, sequence_number}
{:error, %{constraint_violations: [violation | _]}} -> {:rejected, violation.constraint_name, violation.message}
{:error, %{concurrency_violations: [violation | _]}} -> {:conflict, violation.expected_sequence_number, violation.actual_sequence_number}
{:error, other} -> {:error, other}endappend_many_with_result/3 returns every assigned sequence number in order. A rejection is
{:error, %AppendResult{}} carrying :constraint_violations (each with :constraint_name,
:message, :event_type_id, :sequence_number and :details), :concurrency_violations (each
with :event_source_id, :expected_sequence_number and :actual_sequence_number) and :errors.
Transport failures stay plain {:error, reason} terms.
To wait until the observers have processed what you appended, call
Chronicle.EventSequences.AppendResult.wait_for_completion/2 on a successful result.
Reading typed events
Section titled “Reading typed events”get_for_event_source/3 and get_from_sequence_number/2 return the raw wire events. Pass
decode: true to get Chronicle.Events.AppendedEvent structs instead, whose :event is the event
struct you appended:
{:ok, events} = Chronicle.EventSequences.EventLog.get_for_event_source("acc-1", decode: true)
for %{event: %MyApp.Events.FundsDeposited{amount: amount}, context: context} <- events do IO.puts("#{amount} deposited by #{context.caused_by.name} at sequence #{context.sequence_number}")endAn event of a type this client does not know has event: nil and the decoded payload in :raw;
a redacted event has redacted?: true and a :redaction with the reason. Nested structs,
lists of structs and maps decode as described in Chronicle.Serialization.
Event context
Section titled “Event context”Reactors, reducers and decode: true reads all give you a Chronicle.Events.EventContext:
the event type and source, sequence number, occurred, event store and namespace,
correlation_id, the identity that caused the event (caused_by, with on_behalf_of), the
causation chain, tags, hash, subject and appended_generation (the generation that was
actually appended, regardless of which generation a reactor or reducer is written against; 0
means unknown). See Context management for how to set the correlation id, identity
and causation on the way in.
Creation-only guards
Section titled “Creation-only guards”Chronicle.Events.ConcurrencyScope.expecting_no_matching_event/1 refuses an append when a
matching event already exists - the guard for “this can only happen once”:
scope = Chronicle.Events.ConcurrencyScope.expecting_no_matching_event( event_source_id: true, event_types: [MyApp.Events.UserRegistered] )
Chronicle.append("user-1", %MyApp.Events.UserRegistered{}, concurrency_scope: scope)It sends the kernel’s explicit no-matching-event condition. Leaving the expected sequence number
unset is not a substitute: that turns the check off. expecting_no_matching_event/1 also accepts
an existing scope, so it composes with the other pipe-able builders below.
Concurrency
Section titled “Concurrency”Chronicle.Events.ConcurrencyScope offers pipe-able builder helpers in addition to new/2 and
for_event_source/2: with_sequence_number/2, with_event_source/1, with_event_stream_type/2,
with_event_stream_id/2, with_event_source_type/2, with_event_type/2, with_event_types/2,
and expecting_no_matching_event/1 (on a struct as well as on keyword options):
scope = 12 |> ConcurrencyScope.new() |> ConcurrencyScope.with_event_source() |> ConcurrencyScope.with_event_stream_type("Transactions") |> ConcurrencyScope.with_event_type(MyApp.Events.FundsDeposited)ConcurrencyScope.at_tail/2 reads the current tail - narrowed by :event_stream_type,
:event_stream_id, :event_source_type and :event_types the same way the returned scope
narrows - and returns a scope expecting it, in one call:
{:ok, scope} = ConcurrencyScope.at_tail("account-1", event_stream_type: "Transactions")Chronicle.append("account-1", %MyApp.Events.FundsDeposited{amount: 10}, concurrency_scope: scope)Default concurrency strategy
Section titled “Default concurrency strategy”By default a client sends no concurrency constraint for an append that declares none and that
no event source definition derives one for. Opt a client into deriving one automatically with
the :concurrency option on Chronicle.Client:
{Chronicle.Client, connection_string: "chronicle://localhost:35000", event_store: "my-store", concurrency: [default: :optimistic, check_first_append: false]}With default: :optimistic, an append that has neither an explicit nor a definition-derived
scope reads the tail (narrowed exactly the way the append itself is routed) and expects nothing
to have been appended since - mirroring the .NET client’s OptimisticConcurrencyStrategy.
check_first_append: true additionally guards the first append into an empty narrowing (an
empty event source, or the first event under a new stream type/id) with
expecting_no_matching_event/1 instead of leaving it unchecked. An explicit or
definition-derived scope on an append always wins over this default.
Duplicate explicit scopes in a batch
Section titled “Duplicate explicit scopes in a batch”The wire protocol carries one concurrency scope per event source id in a batch. Two unequal
explicit scopes declared for the same event source id in one append_many_for_event_sources/2
call (or one unit of work) are rejected before anything is sent:
{:error, {:duplicate_concurrency_scope, event_source_id}}Equal duplicates (the same guard declared more than once) are accepted and collapse to a single entry.
Streams, metadata and verification
Section titled “Streams, metadata and verification”Chronicle.EventSequences.Streams closes, reopens and inspects stream scopes, reads event
metadata and verifies stored content:
alias Chronicle.EventSequences.Streams
{:ok, sequence_number} = Streams.complete_scope(event_source_id: "acc-1", event_stream_type: "Period", event_stream_id: "2024"){:ok, true} = Streams.scope_completed?(event_source_id: "acc-1", event_stream_type: "Period", event_stream_id: "2024"){:ok, closed} = Streams.closed(event_source_id: "acc-1"):ok = Streams.reopen_scope("Late invoice arrived", event_source_id: "acc-1", event_stream_type: "Period", event_stream_id: "2024")
{:ok, [metadata]} = Streams.get_metadata([sequence_number]){:ok, metadata} = Streams.get_metadata_at(sequence_number){:ok, :equal} = Streams.verify_content(sequence_number, %MyApp.Events.PeriodClosed{period: "2024"})Events that close a scope as part of their own append are declared with
closes_stream; see Constraints.
get_metadata_at/2 reads a single event’s metadata by sequence number, returning
{:ok, metadata} or {:error, :not_found}. get_metadata/2 accepts up to 500 sequence
numbers, de-duplicating them before the request is sent; above that it returns
{:error, {:too_many_event_locators, 500}} without sending one.
Metadata also carries :initiator_type (:unknown | :user | :agent | :system) and
:resolved_caused_by - a Chronicle.EventSequences.ResolvedIdentity carrying the same
subject/name/user name as :caused_by, plus :resolution (how the kernel resolved the
identity at read time: :resolved, :name_unavailable, :missing, :not_set or :system)
and its own :on_behalf_of chain. :caused_by (a plain Chronicle.Identity) also carries
:on_behalf_of now.
Prepared events
Section titled “Prepared events”EventLog.prepare/2 serializes an event once and returns a Chronicle.EventSequences.PreparedEvent
bound to the client, namespace and event sequence that prepared it. Reuse it with
EventLog.append_prepared/3 and Streams.verify_content/3 without re-serializing the event -
useful when you need to verify stored content against exactly what you intended to send, or when
preparing is itself expensive (large payloads, custom serialization):
{:ok, prepared} = EventLog.prepare(%MyApp.Events.PeriodClosed{period: "2024"}):ok = EventLog.append_prepared("acc-1", prepared){:ok, :equal} = Streams.verify_content(sequence_number, prepared)Appending a prepared event through a different client, namespace, or event sequence than the one
that prepared it returns {:error, :prepared_event_belongs_to_another_sequence}. Unlike the .NET
client, single use is not enforced - a prepared event can be appended more than once, exactly like
appending the same event struct twice.
Unknown event types
Section titled “Unknown event types”Every append path (append/3, append_many/3, the _with_result variants,
append_many_for_event_sources/2, EventLog.prepare/2) and Streams.verify_content/3 check that
an event struct uses Chronicle.Events.EventType before building a request, returning
{:error, {:unknown_event_type, module}} instead of raising deep inside request building:
defmodule MyApp.NotAnEvent do defstruct [:value]end
{:error, {:unknown_event_type, MyApp.NotAnEvent}} = Chronicle.append("acc-1", %MyApp.NotAnEvent{value: 1})Payload size
Section titled “Payload size”The kernel refuses a message larger than 4 MiB. Sending one used to break the connection every
observer shares, so the client now checks first and returns
{:error, {:payload_too_large, size, limit}}. A reducer whose read model outgrows the limit
reports the reduction as failed instead. Raise or disable the guard with
config :cratis_chronicle, max_payload_bytes: 8 * 1024 * 1024 (or :infinity) if your kernel is
configured for larger messages.