defmodule Commanded.Aggregates.Aggregate do use TelemetryRegistry use GenServer, restart: :temporary use Commanded.Registration telemetry_event(%{ event: [:commanded, :aggregate, :execute, :start], description: "Emitted when an aggregate starts executing a command", measurements: "%{system_time: integer()}", metadata: """ %{application: Commanded.Application.t(), aggregate_uuid: String.t(), aggregate_state: struct(), aggregate_version: non_neg_integer(), caller: pid(), execution_context: Commanded.Aggregates.ExecutionContext.t()} """ }) telemetry_event(%{ event: [:commanded, :aggregate, :execute, :stop], description: "Emitted when an aggregate stops executing a command", measurements: "%{duration: non_neg_integer()}", metadata: """ %{application: Commanded.Application.t(), aggregate_uuid: String.t(), aggregate_state: struct(), aggregate_version: non_neg_integer(), caller: pid(), execution_context: Commanded.Aggregates.ExecutionContext.t(), events: [map()], error: nil | any()} """ }) telemetry_event(%{ event: [:commanded, :aggregate, :execute, :exception], description: "Emitted when an aggregate raises an exception", measurements: "%{duration: non_neg_integer()}", metadata: """ %{application: Commanded.Application.t(), aggregate_uuid: String.t(), aggregate_state: struct(), aggregate_version: non_neg_integer(), caller: pid(), execution_context: Commanded.Aggregates.ExecutionContext.t(), kind: :throw | :error | :exit, reason: any(), stacktrace: list()} """ }) @moduledoc """ Aggregate is a `GenServer` process used to provide access to an instance of an event sourced aggregate. It allows execution of commands against an aggregate instance, and handles persistence of created events to the configured event store. Concurrent commands sent to an aggregate instance are serialized and executed in the order received. The `Commanded.Commands.Router` module will locate, or start, an aggregate instance when a command is dispatched. By default, an aggregate process will run indefinitely once started. Its lifespan may be controlled by using the `Commanded.Aggregates.AggregateLifespan` behaviour. ## Snapshotting You can configure state snapshots for an aggregate in config. By default snapshots are *not* taken for an aggregate. The following options are available to enable snapshots: - `snapshot_every` - snapshot aggregate state every so many events. Use `nil` to disable snapshotting, or exclude the configuration entirely. - `snapshot_version` - a non-negative integer indicating the version of the aggregate state snapshot. Incrementing this version forces any earlier recorded snapshots to be ignored when rebuilding aggregate state. ### Example In `config/config.exs` enable snapshots for `MyApp.ExampleAggregate` after every ten events: config :my_app, MyApp.Application, snapshotting: %{ MyApp.ExampleAggregate => [ snapshot_every: 10, snapshot_version: 1 ] } ## Telemetry #{telemetry_docs()} """ require Logger alias Commanded.Aggregate.Multi alias Commanded.Aggregates.Aggregate alias Commanded.Aggregates.AggregateStateBuilder alias Commanded.Aggregates.ExecutionContext alias Commanded.Application.Config alias Commanded.Event.Mapper alias Commanded.Event.Upcast alias Commanded.EventStore alias Commanded.EventStore.RecordedEvent alias Commanded.Registration alias Commanded.Snapshotting alias Commanded.Telemetry @type state :: struct() @type uuid :: String.t() @type return_event :: struct() | list(struct()) | {:ok, struct()} | {:ok, list(struct())} @type no_return_event :: :ok | {:ok, []} | nil | [] @doc """ Optionally execute a command against the aggregate. Returns either no event, one event, a list of events, or an error tuple. """ @callback execute(aggregate :: state(), command :: struct()) :: return_event() | no_return_event() | {:error, term()} @doc """ Apply an event to the aggregate state. Returns the updated aggregate. """ @callback apply(aggregate :: state(), event :: struct()) :: state() @optional_callbacks execute: 2 defstruct [ :application, :aggregate_module, :aggregate_uuid, :aggregate_state, :snapshotting, aggregate_version: 0, lifespan_timeout: :infinity ] def start_link(config, opts) do {start_opts, aggregate_opts} = Keyword.split(opts, [:debug, :name, :timeout, :spawn_opt, :hibernate_after]) aggregate_module = Keyword.fetch!(aggregate_opts, :aggregate_module) aggregate_uuid = Keyword.fetch!(aggregate_opts, :aggregate_uuid) unless is_atom(aggregate_module), do: raise(ArgumentError, message: "aggregate module must be an atom") unless is_binary(aggregate_uuid), do: raise(ArgumentError, message: "aggregate identity must be a string") application = Keyword.fetch!(config, :application) snapshotting = Keyword.get(config, :snapshotting, %{}) snapshot_options = Map.get(snapshotting, aggregate_module, []) state = %Aggregate{ application: application, aggregate_module: aggregate_module, aggregate_uuid: aggregate_uuid, snapshotting: Snapshotting.new(application, aggregate_uuid, snapshot_options) } GenServer.start_link(__MODULE__, state, start_opts) end @doc false def name(application, aggregate_module, aggregate_uuid) when is_atom(application) and is_atom(aggregate_module) and is_binary(aggregate_uuid), do: {application, aggregate_module, aggregate_uuid} @doc """ Execute the given command against the aggregate. - `aggregate_module` - the aggregate's module (e.g. `BankAccount`). - `aggregate_uuid` - uniquely identifies an instance of the aggregate. - `context` - includes command execution arguments (see `Commanded.Aggregates.ExecutionContext` for details). - `timeout` - an non-negative integer which specifies how many milliseconds to wait for a reply, or the atom :infinity to wait indefinitely. The default value is five seconds (5,000ms). ## Return values Returns `{:ok, aggregate_version, events}` on success, or `{:error, error}` on failure. - `aggregate_version` - the updated version of the aggregate after executing the command. - `events` - events produced by the command, can be an empty list. """ def execute( application, aggregate_module, aggregate_uuid, %ExecutionContext{} = context, timeout \\ 5_000 ) when is_atom(aggregate_module) and is_binary(aggregate_uuid) and (is_number(timeout) or timeout == :infinity) do name = via_name(application, aggregate_module, aggregate_uuid) try do GenServer.call(name, {:execute_command, context}, timeout) catch :exit, {:noproc, {GenServer, :call, [^name, {:execute_command, ^context}, ^timeout]}} -> {:exit, {:normal, :aggregate_stopped}} :exit, {:normal, {GenServer, :call, [^name, {:execute_command, ^context}, ^timeout]}} -> {:exit, {:normal, :aggregate_stopped}} end end @doc false def aggregate_state(application, aggregate_module, aggregate_uuid, timeout \\ 5_000) do name = via_name(application, aggregate_module, aggregate_uuid) try do GenServer.call(name, :aggregate_state, timeout) catch :exit, {reason, {GenServer, :call, [^name, :aggregate_state, ^timeout]}} when reason in [:normal, :noproc] -> task = Task.async(fn -> snapshot_options = application |> Config.get(:snapshotting) |> Kernel.||(%{}) |> Map.get(aggregate_module, []) %Aggregate{ application: application, aggregate_module: aggregate_module, aggregate_uuid: aggregate_uuid, snapshotting: Snapshotting.new(application, aggregate_uuid, snapshot_options) } |> AggregateStateBuilder.populate() |> Map.fetch!(:aggregate_state) end) case Task.yield(task, timeout) || Task.shutdown(task) do {:ok, result} -> result nil -> exit({:timeout, {GenServer, :call, [name, :aggregate_state, timeout]}}) end end end @doc false def aggregate_version(application, aggregate_module, aggregate_uuid, timeout \\ 5_000) do name = via_name(application, aggregate_module, aggregate_uuid) GenServer.call(name, :aggregate_version, timeout) end @doc false def take_snapshot(application, aggregate_module, aggregate_uuid, timeout \\ 5_000) do name = via_name(application, aggregate_module, aggregate_uuid) GenServer.call(name, :take_snapshot, timeout) end @doc false def shutdown(application, aggregate_module, aggregate_uuid) do name = via_name(application, aggregate_module, aggregate_uuid) GenServer.stop(name) end @doc false @impl GenServer def init(%Aggregate{} = state) do # Initial aggregate state is populated by loading its state snapshot and/or # events from the event store. {:ok, state, {:continue, :populate_aggregate_state}} end @doc false @impl GenServer def handle_continue(:populate_aggregate_state, %Aggregate{} = state) do state = AggregateStateBuilder.populate(state) # Subscribe to aggregate's events to catch any events appended to its stream # by another process, such as directly appended to the event store. {:noreply, state, {:continue, :subscribe_to_events}} end @doc false @impl GenServer def handle_continue(:subscribe_to_events, %Aggregate{} = state) do %Aggregate{application: application, aggregate_uuid: aggregate_uuid} = state :ok = EventStore.subscribe(application, aggregate_uuid) {:noreply, state} end @doc false @impl GenServer def handle_call(:take_snapshot, _from, %Aggregate{} = state) do case do_take_snapshot(state) do {:ok, state} -> {:reply, :ok, state} {:error, _reason} = error -> {:reply, error, state} end end @doc false @impl GenServer def handle_call({:execute_command, %ExecutionContext{} = context}, from, %Aggregate{} = state) do %ExecutionContext{lifespan: lifespan, command: command} = context telemetry_metadata = telemetry_metadata(context, from, state) start_time = telemetry_start(telemetry_metadata) {result, state} = execute_command(context, state) lifespan_timeout = case result do {:ok, []} -> aggregate_lifespan_timeout(lifespan, :after_command, command) {:ok, events} -> aggregate_lifespan_timeout(lifespan, :after_event, events) {:error, error} -> aggregate_lifespan_timeout(lifespan, :after_error, error) {:error, error, _stacktrace} -> aggregate_lifespan_timeout(lifespan, :after_error, error) end formatted_reply = ExecutionContext.format_reply(result, context, state) %Aggregate{aggregate_version: aggregate_version, snapshotting: snapshotting} = state response = if Snapshotting.snapshot_required?(snapshotting, aggregate_version) do send(self(), {:take_snapshot, lifespan_timeout}) # Don't reply with a lifetime because we just asked for a snapshot to # be taken. When it finishes, it will set the timeout. {:reply, formatted_reply, state} else state = %Aggregate{state | lifespan_timeout: lifespan_timeout} reply_with_lifespan(formatted_reply, state) end telemetry_metadata = telemetry_metadata(context, from, state) telemetry_stop(start_time, telemetry_metadata, result) response end @doc false @impl GenServer def handle_call(:aggregate_state, _from, %Aggregate{} = state) do %Aggregate{aggregate_state: aggregate_state} = state reply_with_lifespan(aggregate_state, state) end @doc false @impl GenServer def handle_call(:aggregate_version, _from, %Aggregate{} = state) do %Aggregate{aggregate_version: aggregate_version} = state reply_with_lifespan(aggregate_version, state) end @doc false @impl GenServer def handle_info({:events, events}, %Aggregate{} = state) do %Aggregate{application: application} = state Logger.debug(describe(state) <> " received events: " <> inspect(events)) try do state = events |> Enum.reject(&event_already_seen?(&1, state)) |> Upcast.upcast_event_stream(additional_metadata: %{application: application}) |> Enum.reduce(state, &handle_event/2) noreply_with_lifespan(state) catch {:error, error} -> Logger.debug(describe(state) <> " stopping due to: " <> inspect(error)) # Stop after event handling returned an error {:stop, error, state} end end @doc false @impl GenServer def handle_info({:take_snapshot, lifespan_timeout}, %Aggregate{} = state) do state = %Aggregate{state | lifespan_timeout: lifespan_timeout} case do_take_snapshot(state) do {:ok, state} -> noreply_with_lifespan(state) {:error, _error} -> noreply_with_lifespan(state) end end @doc false @impl GenServer def handle_info(:timeout, %Aggregate{} = state) do Logger.debug(describe(state) <> " stopping due to inactivity timeout") {:stop, :normal, state} end @doc false @impl GenServer def handle_info(message, %Aggregate{} = state) do Logger.debug("received unexpected message in handle_info/2: " <> inspect(message)) noreply_with_lifespan(state) end defp event_already_seen?(%RecordedEvent{} = event, %Aggregate{} = state) do %RecordedEvent{stream_version: stream_version} = event %Aggregate{aggregate_version: aggregate_version} = state stream_version <= aggregate_version end # Handle events appended to the aggregate's stream, received by its # event store subscription, by applying any missed events to its state. defp handle_event(%RecordedEvent{} = event, %Aggregate{} = state) do %RecordedEvent{data: data, stream_version: stream_version} = event %Aggregate{ aggregate_module: aggregate_module, aggregate_state: aggregate_state, aggregate_version: aggregate_version } = state if stream_version == aggregate_version + 1 do # Apply event to aggregate's state %Aggregate{ state | aggregate_version: stream_version, aggregate_state: aggregate_module.apply(aggregate_state, data) } else Logger.debug(describe(state) <> " received an unexpected event: " <> inspect(event)) # Throw an error when an unexpected event is received throw({:error, :unexpected_event_received}) end end defp aggregate_lifespan_timeout(lifespan, function_name, args) do # Take the last event or the command or error args = args |> List.wrap() |> Enum.take(-1) case apply(lifespan, function_name, args) do timeout when timeout in [:infinity, :hibernate] -> timeout :stop -> {:stop, :normal} {:stop, _reason} = reply -> reply timeout when is_integer(timeout) and timeout >= 0 -> timeout invalid -> Logger.warning( "Invalid timeout for aggregate lifespan " <> inspect(lifespan) <> ", expected a non-negative integer, `:infinity`, `:hibernate`, `:stop`, or `{:stop, reason}` but got: " <> inspect(invalid) ) :infinity end end defp before_execute_command(_aggregate_state, %ExecutionContext{before_execute: nil}), do: :ok defp before_execute_command(aggregate_state, %ExecutionContext{} = context) do %ExecutionContext{handler: handler, before_execute: before_execute} = context Kernel.apply(handler, before_execute, [aggregate_state, context]) end defp execute_command(%ExecutionContext{} = context, %Aggregate{} = state) do %ExecutionContext{command: command, handler: handler, function: function} = context %Aggregate{aggregate_state: aggregate_state} = state Logger.debug(describe(state) <> " executing command: " <> inspect(command)) with :ok <- before_execute_command(aggregate_state, context) do case Kernel.apply(handler, function, [aggregate_state, command]) do {:error, _error} = reply -> {reply, state} none when none in [:ok, nil, []] -> {{:ok, []}, state} %Multi{} = multi -> case Multi.run(multi) do {:error, _error} = reply -> {reply, state} {aggregate_state, pending_events} -> persist_events(pending_events, aggregate_state, context, state) end {:ok, pending_events} -> apply_and_persist_events(pending_events, context, state) pending_events -> apply_and_persist_events(pending_events, context, state) end else {:error, _error} = reply -> {reply, state} end rescue error -> stacktrace = __STACKTRACE__ Logger.error(Exception.format(:error, error, stacktrace)) {{:error, error, stacktrace}, state} end defp apply_and_persist_events(pending_events, context, %Aggregate{} = state) do %Aggregate{aggregate_module: aggregate_module, aggregate_state: aggregate_state} = state pending_events = List.wrap(pending_events) aggregate_state = apply_events(aggregate_module, aggregate_state, pending_events) persist_events(pending_events, aggregate_state, context, state) end defp apply_events(aggregate_module, aggregate_state, events) do Enum.reduce(events, aggregate_state, &aggregate_module.apply(&2, &1)) end defp persist_events(pending_events, aggregate_state, context, %Aggregate{} = state) do %Aggregate{aggregate_version: expected_version} = state with :ok <- append_to_stream(pending_events, context, state) do aggregate_version = expected_version + length(pending_events) state = %Aggregate{ state | aggregate_state: aggregate_state, aggregate_version: aggregate_version } {{:ok, pending_events}, state} else {:error, :wrong_expected_version} -> # Fetch missing events from event store state = AggregateStateBuilder.rebuild_from_events(state) # Retry command if there are any attempts left case ExecutionContext.retry(context) do {:ok, context} -> Logger.debug(describe(state) <> " wrong expected version, retrying command") execute_command(context, state) reply -> Logger.debug(describe(state) <> " wrong expected version, but not retrying command") {reply, state} end {:error, _error} = reply -> {reply, state} end end defp append_to_stream([], _context, _state), do: :ok defp append_to_stream(pending_events, %ExecutionContext{} = context, %Aggregate{} = state) do %Aggregate{ application: application, aggregate_uuid: aggregate_uuid, aggregate_version: expected_version } = state %ExecutionContext{ causation_id: causation_id, correlation_id: correlation_id, metadata: metadata } = context event_data = Mapper.map_to_event_data(pending_events, causation_id: causation_id, correlation_id: correlation_id, metadata: metadata ) EventStore.append_to_stream(application, aggregate_uuid, expected_version, event_data) end defp do_take_snapshot(%Aggregate{} = state) do %Aggregate{ aggregate_state: aggregate_state, aggregate_version: aggregate_version, snapshotting: snapshotting } = state Logger.debug(describe(state) <> " recording snapshot") case Snapshotting.take_snapshot(snapshotting, aggregate_version, aggregate_state) do {:ok, snapshotting} -> {:ok, %Aggregate{state | snapshotting: snapshotting}} {:error, reason} = error -> Logger.warning(describe(state) <> " snapshot failed due to: " <> inspect(reason)) error end end defp telemetry_start(telemetry_metadata) do Telemetry.start([:commanded, :aggregate, :execute], telemetry_metadata) end defp telemetry_stop(start_time, telemetry_metadata, result) do event_prefix = [:commanded, :aggregate, :execute] case result do {:ok, events} -> Telemetry.stop(event_prefix, start_time, Map.put(telemetry_metadata, :events, events)) {:error, error} -> Telemetry.stop(event_prefix, start_time, Map.put(telemetry_metadata, :error, error)) {:error, error, stacktrace} -> Telemetry.exception( event_prefix, start_time, :error, error, stacktrace, telemetry_metadata ) end end defp telemetry_metadata(%ExecutionContext{} = context, from, %Aggregate{} = state) do %Aggregate{ application: application, aggregate_uuid: aggregate_uuid, aggregate_state: aggregate_state, aggregate_version: aggregate_version } = state {pid, _ref} = from %{ application: application, aggregate_uuid: aggregate_uuid, aggregate_state: aggregate_state, aggregate_version: aggregate_version, caller: pid, execution_context: context } end defp via_name(application, aggregate_module, aggregate_uuid) do name = name(application, aggregate_module, aggregate_uuid) Registration.via_tuple(application, name) end defp describe(%Aggregate{} = aggregate) do %Aggregate{ aggregate_module: aggregate_module, aggregate_uuid: aggregate_uuid, aggregate_version: aggregate_version } = aggregate "#{inspect(aggregate_module)}<#{aggregate_uuid}@#{aggregate_version}>" end defp reply_with_lifespan(reply, state) do %Aggregate{lifespan_timeout: lifespan_timeout} = state case lifespan_timeout do {:stop, reason} -> {:stop, reason, reply, state} lifespan_timeout -> {:reply, reply, state, lifespan_timeout} end end defp noreply_with_lifespan(state) do %Aggregate{lifespan_timeout: lifespan_timeout} = state case lifespan_timeout do {:stop, reason} -> {:stop, reason, state} lifespan_timeout -> {:noreply, state, lifespan_timeout} end end end