Behaviour and macro for defining Chronicle reactors.
Reactors observe events and react to them with side effects — sending emails, calling external APIs, updating caches, etc. They run in Chronicle's observation pipeline and receive events as they are appended.
Defining a reactor
Use Chronicle.Reactors.Reactor in a module and implement the handle/2 callback.
Declare which event types the reactor handles using the @handles module
attribute.
defmodule MyApp.Reactors.NotificationReactor do
use Chronicle.Reactors.Reactor
@handles MyApp.Events.AccountOpened
@handles MyApp.Events.FundsDeposited
@impl true
def handle(%MyApp.Events.AccountOpened{} = event, _context) do
# Send welcome email
MyApp.Mailer.send_welcome(event.owner_name)
:ok
end
def handle(%MyApp.Events.FundsDeposited{} = event, _context) do
# Notify account holder
:ok
end
endOptions for use Chronicle.Reactors.Reactor
:id— a stable string identifier for this reactor. Defaults to the module's full name. Changing this value causes Chronicle to treat this as a different reactor and will reset its observation position. A defaulted id (pin it explicitly to silence this) emits a one-time warning when discovered — seeChronicle.Discovery.DefaultedIdWarning.:middleware— modules implementingChronicle.Middlewarethat wrap everyhandle/2call of this reactor (in addition to application-wideconfig :cratis_chronicle, middleware:):once_only— controls replay (mirrors .NET's[OnceOnly]). Reactors are replayable by default.once_only: trueregisters the reactor as not replayable (IsNotReplayable: true), so the kernel never replays it - use it for side effects such as sending an email.once_only: [MyApp.Events.OrderShipped]keeps the reactor replayable but skips the handlers of the listed events while replaying.:start_at_tail—truemakes the reactor begin at the tail of the event sequence the first time Chronicle sees it, so it observes only what is appended from then on instead of catching up on all history (see "Starting at the tail" below). Defaults tofalse.:generation_delivery—:compatibility(default) or:pinned. See "Pinned generation delivery" below.
Starting at the tail
A new reactor catches up from the very beginning of the event sequence, as ordinary
observation, so a reactor whose handlers cause side effects - sending an email, calling an
external system - acts on every historical event the moment it is deployed. once_only
does not prevent that, because it only excludes replay. start_at_tail: true does.
use Chronicle.Reactors.Reactor, start_at_tail: trueIt only affects the first registration: a reactor Chronicle already holds a position for
keeps it, so the option never skips events after a restart. Changing the module name without
pinning :id makes a new reactor, which starts at the tail again. An explicit replay still
replays from the beginning. Both the Chronicle server and this client must support it; a server
that predates it ignores the request and the reactor catches up from the beginning.
Pinned generation delivery
By default (:compatibility), Chronicle upgrades every event to the handled module's own
generation before delivery, so handle/2 always sees that generation's shape regardless of
which generation was actually appended. Set generation_delivery: :pinned to receive each
event exactly as its own generation was appended instead - useful when a handler is written
against an older generation and must see its original content rather than the upgraded one.
context.appended_generation then tells you which generation was actually appended (0
means unknown - an older kernel, or an event appended outside a registered event source).
A client-wide default applies to every reactor that doesn't set :generation_delivery
itself: pass event_generation_delivery: :pinned to Chronicle.Client. A reactor's own
:generation_delivery option always wins over the client default.
With :pinned, a reactor that handles two generations of the same event type id has no
single generation to pin to and raises
Chronicle.Observers.MultipleGenerationsOfEventTypeInObserver on start.
Replay
While the kernel replays a reactor, context.replaying? is true. Define the optional
handle_replay/2 callback to react differently during a replay (mirrors .NET's [Replay]):
when it is defined it is called instead of handle/2 for replayed events.
Replay exclusion is not exactly-once delivery. Recovering a failed partition can deliver an event again as an ordinary observation; guard side effects with
context.delivery_id.
Registering with Chronicle.Client
{Chronicle.Client,
...
reactors: [MyApp.Reactors.NotificationReactor]}Event context
The second argument to handle/2 is a Chronicle.Events.EventContext struct (a map, so
existing pattern matches on keys such as :event_source_id keep working). It carries the
event type, event source and stream, sequence number, occurred time, event store, namespace,
correlation id, :caused_by identity, causation chain, tags, hash and subject, plus a
stable :delivery_id for building idempotency keys (the same value on every redelivery of
the same event to this reactor).
Return values
handle/2 must return one of:
:ok— success, no side effect.{:error, reason}— failure. Reported back to Chronicle as a failed partition, which can be retried or replayed.{:ok, event_or_events}— success, with one or more events to append as a side effect.event_or_eventsmay be:- a single event struct — appended to the triggering event source id.
- a list of event structs — appended atomically (single append-many) to the triggering event source id.
- a
Chronicle.EventSequences.EventForEventSourceIdstruct — appended to its own explicit event source id. - a list of
EventForEventSourceIdstructs (optionally mixed with bare event structs, which are then targeted at the triggering event source id) — appended atomically across all their (possibly different) event source ids.
If appending a side effect fails, handle/2's overall result becomes
{:error, reason} — reported the same way as a plain {:error, reason} return.
Replay lifecycle (optional)
Implement any of on_replay_begin/0, on_replay_end/0,
on_partition_replay_begin/1, on_partition_replay_end/1 to be notified when
Chronicle starts or finishes replaying this reactor — either as a whole, or
for a single partition (event source). All four are optional; implement only
the ones you need.
@impl true
def on_replay_begin, do: Logger.info("Replay starting")
@impl true
def on_replay_end, do: Logger.info("Replay finished")These are notifications, not events to handle — they run outside the normal
handle/2 dispatch, are not subject to failed-partition tracking, and a
raised exception is logged and swallowed rather than reported to Chronicle.
Summary
Callbacks
Handles an event dispatched by Chronicle.
Optional. Handles an event while the reactor is being replayed, instead of handle/2.
Called when replay of a single partition (event source) begins.
Called when replay of a single partition (event source) ends.
Called when a full replay of this reactor begins.
Called when a full replay of this reactor ends.
Callbacks
@callback handle(event :: struct(), context :: map()) :: :ok | {:error, term()} | {:ok, struct() | [struct()]}
Handles an event dispatched by Chronicle.
Called once per event for each partition. Must return :ok, {:error, reason},
or {:ok, event_or_events} to append a side effect (see moduledoc).
@callback handle_replay(event :: struct(), context :: map()) :: :ok | {:error, term()} | {:ok, struct() | [struct()]}
Optional. Handles an event while the reactor is being replayed, instead of handle/2.
Same contract and return values as handle/2.
Called when replay of a single partition (event source) begins.
Called when replay of a single partition (event source) ends.
@callback on_replay_begin() :: any()
Called when a full replay of this reactor begins.
@callback on_replay_end() :: any()
Called when a full replay of this reactor ends.