Skip to content

Commit a64580b

Browse files
yordisdrteethTylerPachalapre
committed
chore: sync with upstream commanded/commanded
Co-authored-by: Benjamin Moss <drteeth@gmail.com> Co-authored-by: Tyler Pachal <tylerpachalandfriends@gmail.com> Co-authored-by: Adrien Pré <adrien.pre@gmail.com> Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
2 parents 341b91d + 265c661 commit a64580b

7 files changed

Lines changed: 999 additions & 19 deletions

File tree

guides/explanations/events.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,71 @@ You request the consistency guarantee, either `:strong` or `:eventual`, when dis
223223

224224
An event handler is a `GenServer` process that subscribes to the configured event store. For each event persisted to the store the `handle/2` callback is called, passing the domain event and its metadata.
225225

226+
## Batching
227+
228+
Event handlers can process events in batches for improved throughput. Specify `batch_size` and implement `handle_batch/1`:
229+
230+
```elixir
231+
defmodule AccountProjector do
232+
use Commanded.Event.Handler,
233+
application: BankingApp,
234+
name: "AccountProjector",
235+
batch_size: 100
236+
237+
def handle_batch(events) do
238+
# events is a list of {event_data, metadata} tuples
239+
events
240+
|> Enum.map(fn {event, metadata} -> transform(event, metadata) end)
241+
|> Repo.insert_all()
242+
243+
:ok
244+
end
245+
end
246+
```
247+
248+
All events in the batch are acknowledged when `:ok` is returned. Batching and concurrency cannot be used together.
249+
250+
### Batch failure and idempotency
251+
252+
If `handle_batch/1` returns `{:error, reason}` or crashes, **none** of the events in the batch are acknowledged. The EventStore will redeliver the entire batch.
253+
254+
In case you wanted to make your handler idempotent per event, you could track the last processed `event_number` to skip already-seen events.
255+
256+
### Batch timeout
257+
258+
Add `batch_timeout` to flush batches on size OR time, whichever comes first:
259+
260+
```elixir
261+
use Commanded.Event.Handler,
262+
batch_size: 50,
263+
batch_timeout: 100 # milliseconds
264+
```
265+
266+
The batch flushes when either 50 events accumulate or 100ms elapse. Default is `:infinity` (no timeout).
267+
268+
**Recommended starting values:** `batch_size: 50, batch_timeout: 100`
269+
270+
### Monitoring with Telemetry
271+
272+
Track `flush_reason` to tune batch parameters:
273+
274+
```elixir
275+
:telemetry.attach(
276+
"batch-monitor",
277+
[:commanded, :event, :batch, :stop],
278+
fn _event, measurements, metadata, _config ->
279+
# metadata.flush_reason is :size | :timeout | :immediate
280+
# metadata.event_count is the batch size
281+
# measurements.duration is processing time
282+
283+
if metadata.flush_reason == :timeout and metadata.event_count < 10 do
284+
Logger.warning("Small timeout flush - consider tuning batch_size")
285+
end
286+
end,
287+
nil
288+
)
289+
```
290+
226291
## Reset an EventHandler
227292

228293
An event handler can be reset (using a mix task), it will restart the event store subscription from the configured

lib/commanded/aggregates/aggregate_state_builder.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ defmodule Commanded.Aggregates.AggregateStateBuilder do
6262
"""
6363
})
6464

65+
@moduledoc """
66+
Builds an aggregate's state by loading its snapshot and/or events from the
67+
event store.
68+
69+
## Telemetry
70+
71+
#{telemetry_docs()}
72+
73+
"""
74+
6575
@read_event_batch_size 1_000
6676

6777
@doc """

lib/commanded/commands/router.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,22 @@ defmodule Commanded.Commands.Router do
204204
205205
:ok = BankApp.dispatch(command, metadata: %{"ip_address" => "127.0.0.1"})
206206
207+
## Causation id
208+
209+
You can associate a `:causation_id` with the dispatched command, which will
210+
also be persisted as the `causation_id` of every event the command produces.
211+
This lets you reconstruct a chain-of-causation across multiple events
212+
without persisting commands separately.
213+
214+
:ok = BankApp.dispatch(command, causation_id: some_event_id)
215+
216+
When `:causation_id` is not provided, the command's own `command_uuid` is
217+
used as the resulting events' `causation_id`.
218+
219+
Process managers and event handlers that dispatch follow-up commands
220+
already propagate the handled event's id this way, so the full causation
221+
chain is observable on the events alone.
222+
207223
"""
208224

209225
alias Commanded.Aggregates.DefaultLifespan

lib/commanded/event/handler.ex

Lines changed: 147 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ defmodule Commanded.Event.Handler do
6161
handler_state: map(),
6262
first_event_id: binary(),
6363
last_event_id: binary(),
64-
event_count: integer()}
64+
event_count: integer(),
65+
flush_reason: :size | :timeout | :immediate}
6566
"""
6667
})
6768

@@ -79,6 +80,7 @@ defmodule Commanded.Event.Handler do
7980
last_event_id: binary(),
8081
event_count: integer(),
8182
recorded_event: RecordedEvent.t() | nil,
83+
flush_reason: :size | :timeout | :immediate,
8284
optional(:error) => any()}
8385
"""
8486
})
@@ -97,6 +99,7 @@ defmodule Commanded.Event.Handler do
9799
first_event_id: binary(),
98100
last_event_id: binary(),
99101
event_count: integer(),
102+
flush_reason: :size | :timeout | :immediate,
100103
kind: :throw | :error | :exit,
101104
reason: any(),
102105
optional(:stacktrace) => list()}
@@ -646,8 +649,14 @@ defmodule Commanded.Event.Handler do
646649
- :subscribe_to - which stream to subscribe to can be either `:all` to
647650
subscribe to all events or a named stream (default: `:all`).
648651
649-
- :batch_size - the size of batches to deliver to `handle_batch` in batched
650-
mode.
652+
- :batch_size - controls the EventStore subscription's in-flight buffer
653+
size. When `batch_timeout` is also set, this is the maximum number of
654+
events the handler buffers before flushing. Enables `handle_batch/1`.
655+
656+
- :batch_timeout - maximum milliseconds to wait for events to accumulate
657+
in the handler buffer before flushing. Defaults to `:infinity` (no
658+
buffering; events processed immediately as delivered). Requires
659+
`:batch_size`.
651660
652661
The default options supported by `GenServer.start_link/3` are supported,
653662
including the `:hibernate_after` option which allows the process to go
@@ -734,7 +743,8 @@ defmodule Commanded.Event.Handler do
734743
:subscribe_to,
735744
:subscription_opts,
736745
:state,
737-
:batch_size
746+
:batch_size,
747+
:batch_timeout
738748
]
739749

740750
@doc false
@@ -753,6 +763,12 @@ defmodule Commanded.Event.Handler do
753763
"both `:concurrency` and `:batch_size` are specified, this is not yet supported. Please choose one or the other."
754764
end
755765

766+
if Keyword.has_key?(config, :batch_timeout) and not Keyword.has_key?(config, :batch_size) do
767+
raise ArgumentError,
768+
inspect(module) <>
769+
" :batch_timeout requires :batch_size. Remove the timeout or configure batching."
770+
end
771+
756772
{application, config} = Keyword.pop(config, :application)
757773

758774
unless application do
@@ -766,20 +782,37 @@ defmodule Commanded.Event.Handler do
766782
end
767783

768784
{batch_size, config} = Keyword.pop(config, :batch_size)
785+
{batch_timeout, config} = Keyword.pop(config, :batch_timeout, :infinity)
786+
787+
unless is_nil(batch_size) or (is_integer(batch_size) and batch_size > 0) do
788+
raise ArgumentError,
789+
inspect(module) <>
790+
" :batch_size must be nil or positive integer, got: " <> inspect(batch_size)
791+
end
792+
793+
unless batch_timeout == :infinity or (is_integer(batch_timeout) and batch_timeout > 0) do
794+
raise ArgumentError,
795+
inspect(module) <>
796+
" :batch_timeout must be :infinity or positive integer, got: " <>
797+
inspect(batch_timeout)
798+
end
769799

770800
config =
771801
case batch_size do
772802
nil ->
773-
# Delegate to `handle_event/2` when `batch_size` is not specified
774-
Keyword.put(config, :handler_callback, :event)
803+
config
804+
|> Keyword.put(:handler_callback, :event)
805+
|> Keyword.put(:batch_size, nil)
806+
|> Keyword.put(:batch_timeout, :infinity)
775807

776-
size when is_integer(size) ->
808+
size when is_integer(size) and size > 0 ->
777809
config
778810
|> Keyword.update(:subscription_opts, [buffer_size: size], fn opts ->
779811
Keyword.put(opts, :buffer_size, size)
780812
end)
781-
# Delegate to `handle_batch/2` when `batch_size` is specified
782813
|> Keyword.put(:handler_callback, :batch)
814+
|> Keyword.put(:batch_size, size)
815+
|> Keyword.put(:batch_timeout, batch_timeout)
783816
end
784817

785818
{application, name, config}
@@ -823,7 +856,11 @@ defmodule Commanded.Event.Handler do
823856
:handler_state,
824857
:last_seen_event,
825858
:subscription,
826-
:subscribe_timer
859+
:subscribe_timer,
860+
:batch_size,
861+
:batch_timer_ref,
862+
batch_timeout: :infinity,
863+
batch_buffer: []
827864
]
828865

829866
@doc false
@@ -842,7 +879,11 @@ defmodule Commanded.Event.Handler do
842879
handler_callback: Keyword.fetch!(handler_opts, :handler_callback),
843880
handler_state: Keyword.get(handler_opts, :state),
844881
consistency: consistency,
845-
subscription: subscription
882+
subscription: subscription,
883+
batch_size: Keyword.get(handler_opts, :batch_size),
884+
batch_timeout: Keyword.get(handler_opts, :batch_timeout, :infinity),
885+
batch_timer_ref: nil,
886+
batch_buffer: []
846887
}
847888

848889
with {:ok, pid} <- Registration.start_link(application, name, __MODULE__, handler, start_opts) do
@@ -940,18 +981,22 @@ defmodule Commanded.Event.Handler do
940981
@doc false
941982
@impl GenServer
942983
def handle_info({:events, events}, state) do
943-
%Handler{handler_callback: callback} = state
944-
945-
processor =
946-
case callback do
947-
:event -> fn events, state -> Enum.reduce(events, state, &handle_event/2) end
948-
:batch -> &handle_batch/2
949-
end
984+
%Handler{handler_callback: callback, batch_timeout: batch_timeout} = state
950985

951986
Logger.debug(describe(state) <> " received events: #{inspect(events)}")
952987

953988
try do
954-
state = processor.(events, state)
989+
state =
990+
case {callback, batch_timeout} do
991+
{:event, _} ->
992+
Enum.reduce(events, state, &handle_event/2)
993+
994+
{:batch, :infinity} ->
995+
handle_batch(events, state)
996+
997+
{:batch, _timeout} ->
998+
buffer_and_maybe_flush(events, state)
999+
end
9551000

9561001
{:noreply, state}
9571002
catch
@@ -961,6 +1006,24 @@ defmodule Commanded.Event.Handler do
9611006
end
9621007
end
9631008

1009+
@doc false
1010+
@impl GenServer
1011+
def handle_info(:flush_batch_timeout, state) do
1012+
%Handler{batch_buffer: buffer} = state
1013+
1014+
Logger.debug(
1015+
describe(state) <> " flushing batch due to timeout: #{length(buffer || [])} event(s)"
1016+
)
1017+
1018+
try do
1019+
state = flush_batch_buffer(state, :timeout)
1020+
{:noreply, state}
1021+
catch
1022+
{:error, reason} ->
1023+
{:stop, reason, state}
1024+
end
1025+
end
1026+
9641027
@doc false
9651028
@impl GenServer
9661029
def handle_info(
@@ -1131,6 +1194,70 @@ defmodule Commanded.Event.Handler do
11311194
end
11321195
end
11331196

1197+
defp buffer_and_maybe_flush(events, %Handler{} = state) do
1198+
%Handler{batch_buffer: buffer, batch_size: batch_size} = state
1199+
1200+
new_buffer = (buffer || []) ++ events
1201+
state = %Handler{state | batch_buffer: new_buffer} |> maybe_start_batch_timer()
1202+
1203+
if length(new_buffer) >= batch_size do
1204+
state
1205+
|> cancel_batch_timer()
1206+
|> flush_batch_buffer(:size)
1207+
else
1208+
state
1209+
end
1210+
end
1211+
1212+
defp flush_batch_buffer(%Handler{batch_buffer: buffer} = state, _flush_reason)
1213+
when buffer in [nil, []],
1214+
do: state
1215+
1216+
defp flush_batch_buffer(%Handler{batch_buffer: buffer} = state, flush_reason) do
1217+
Logger.debug(
1218+
describe(state) <> " flushing batch of #{length(buffer)} event(s) due to #{flush_reason}"
1219+
)
1220+
1221+
state = %Handler{state | batch_buffer: [], batch_timer_ref: nil}
1222+
handle_batch(buffer, %{flush_reason: flush_reason}, state)
1223+
end
1224+
1225+
defp maybe_start_batch_timer(%Handler{batch_buffer: buffer} = state) when buffer in [nil, []],
1226+
do: state
1227+
1228+
defp maybe_start_batch_timer(%Handler{batch_timeout: :infinity} = state), do: state
1229+
1230+
defp maybe_start_batch_timer(
1231+
%Handler{batch_timer_ref: nil, batch_timeout: batch_timeout} = state
1232+
)
1233+
when is_integer(batch_timeout) do
1234+
timer_ref = Process.send_after(self(), :flush_batch_timeout, batch_timeout)
1235+
%Handler{state | batch_timer_ref: timer_ref}
1236+
end
1237+
1238+
defp maybe_start_batch_timer(state), do: state
1239+
1240+
defp cancel_batch_timer(%Handler{batch_timer_ref: nil} = state), do: state
1241+
1242+
defp cancel_batch_timer(%Handler{batch_timer_ref: ref} = state) do
1243+
case Process.cancel_timer(ref) do
1244+
false ->
1245+
drain_flush_batch_timeout_message()
1246+
%Handler{state | batch_timer_ref: nil}
1247+
1248+
_remaining ->
1249+
%Handler{state | batch_timer_ref: nil}
1250+
end
1251+
end
1252+
1253+
defp drain_flush_batch_timeout_message do
1254+
receive do
1255+
:flush_batch_timeout -> :ok
1256+
after
1257+
0 -> :ok
1258+
end
1259+
end
1260+
11341261
defp handle_batch(events, context \\ %{}, handler)
11351262

11361263
defp handle_batch(events, context, %Handler{last_seen_event: last_seen_event} = state)
@@ -1538,7 +1665,8 @@ defmodule Commanded.Event.Handler do
15381665
recorded_event: nil,
15391666
first_event_id: first_event.event_id,
15401667
last_event_id: last_event.event_id,
1541-
event_count: length(recorded_events)
1668+
event_count: length(recorded_events),
1669+
flush_reason: Map.get(context, :flush_reason, :immediate)
15421670
}
15431671
end
15441672

0 commit comments

Comments
 (0)