From 50068953a56acef5341c2577381aab3d81dcf10d Mon Sep 17 00:00:00 2001 From: Burmaja Milan Date: Mon, 8 Jun 2026 12:51:53 +0100 Subject: [PATCH 1/9] Handle :try_another_node error response --- .tool-versions | 4 +- lib/aggregate.ex | 6 +-- lib/application.ex | 1 + lib/dispatcher.ex | 108 ++++++++++++++++++++++++++------------- lib/message.ex | 1 - lib/router.ex | 9 ++-- lib/scheduler.ex | 4 +- lib/service_registry.ex | 7 +++ mix.exs | 4 +- test/dispatcher_test.exs | 8 +++ test/router_test.exs | 10 +++- test/scheduler_test.exs | 1 - test/support/router.ex | 7 +++ 13 files changed, 117 insertions(+), 53 deletions(-) diff --git a/.tool-versions b/.tool-versions index 3d664e3..2672640 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -elixir 1.19.5-otp-28 -erlang 28.3.1 +elixir 1.20.0-otp-29 +erlang 29.0.1 diff --git a/lib/aggregate.ex b/lib/aggregate.ex index 84fdcf2..a3f92ac 100644 --- a/lib/aggregate.ex +++ b/lib/aggregate.ex @@ -14,7 +14,7 @@ defmodule X3m.System.Aggregate do quote do @spec unquote(msg_name)(X3m.System.Message.t(), X3m.System.Aggregate.State.t()) :: {:block | :noblock, X3m.System.Message.t(), X3m.System.Aggregate.State.t()} - def unquote(msg_name)(%X3m.System.Message{} = message, state) do + def unquote(msg_name)(%X3m.System.Message{} = message, %State{} = state) do if MapSet.member?(state.processed_messages, message.id) do Logger.warning("This message was already processed by aggregate. Returning :ok") message = X3m.System.Message.ok(message) @@ -41,7 +41,7 @@ defmodule X3m.System.Aggregate do quote do @spec unquote(msg_name)(X3m.System.Message.t(), X3m.System.Aggregate.State.t()) :: {:block | :noblock, X3m.System.Message.t(), X3m.System.Aggregate.State.t()} - def unquote(msg_name)(%X3m.System.Message{} = message, state) do + def unquote(msg_name)(%X3m.System.Message{} = message, %State{} = state) do if MapSet.member?(state.processed_messages, message.id) do Logger.warning("This message was already processed by aggregate. Returning :ok") message = X3m.System.Message.ok(message) @@ -87,7 +87,7 @@ defmodule X3m.System.Aggregate do new_client_state = apply_event(event, state.client_state) processed_messages = - if id = processed_message_id(metadata) do + if id = apply(__MODULE__, :processed_message_id, [metadata]) do MapSet.put(state.processed_messages, id) else state.processed_messages diff --git a/lib/application.ex b/lib/application.ex index 5476ee5..90a95b6 100644 --- a/lib/application.ex +++ b/lib/application.ex @@ -7,6 +7,7 @@ defmodule X3m.System.Application do X3m.System.ServiceTelemetryHandler.setup() children = [ + {Task.Supervisor, name: X3m.System.TaskSupervisor}, X3m.System.NodeMonitor, X3m.System.ServiceRegistry ] diff --git a/lib/dispatcher.ex b/lib/dispatcher.ex index 6f86a78..904365f 100644 --- a/lib/dispatcher.ex +++ b/lib/dispatcher.ex @@ -12,11 +12,14 @@ defmodule X3m.System.Dispatcher do %{message: message, caller_node: Node.self()} ) - case discover_service(message) do - {:unavailable, _message} -> + message + |> discover_service() + |> case do + :not_found -> :service_unavailable - {node, mod} -> + # we can ask any node for authorization + [{node, mod} | _] -> _authorized?(node, mod, message) end end @@ -55,8 +58,10 @@ defmodule X3m.System.Dispatcher do %{message: message, caller_node: Node.self()} ) - case discover_service(message) do - {:unavailable, message} -> + message + |> discover_service() + |> case do + :not_found -> Instrumenter.execute( :service_not_found, %{time: DateTime.utc_now(), duration: Instrumenter.duration(mono_start)}, @@ -65,39 +70,45 @@ defmodule X3m.System.Dispatcher do _unavailable(message) - {node, mod} -> - Instrumenter.execute( - :service_found, - %{time: DateTime.utc_now(), duration: Instrumenter.duration(mono_start)}, - %{message: message, caller_node: Node.self(), service_node: node} - ) - - mono_start = System.monotonic_time() - - Instrumenter.execute( - :invoking_service, - %{start: DateTime.utc_now(), mono_start: mono_start}, - %{message: message, caller_node: Node.self(), service_node: node} - ) - - message = _dispatch(node, mod, message, timeout) + nodes -> + _try_on_nodes(nodes, message, fn node, mod, message -> + _dispatch(node, mod, message, timeout, mono_start) + end) + end + end - Instrumenter.execute( - :service_responded, - %{time: DateTime.utc_now(), duration: Instrumenter.duration(mono_start)}, - %{message: message, caller_node: Node.self(), service_node: node} - ) + defp _try_on_nodes(nodes, message, fun) when is_list(nodes) do + {message, _} = + nodes + |> Enum.shuffle() + |> Enum.reduce_while({%Message{} = message, []}, fn + {node, mod}, {%Message{} = message, nodes} -> + node + |> fun.(mod, message) + |> case do + %Message{response: {:error, {:try_another_node, reason}}} = message -> + nodes = [{node, reason} | nodes] + message = %{message | response: {:error, {:no_nodes_available, nodes}}} + {:cont, {message, nodes}} + + %Message{} = message -> + {:halt, {message, nil}} + end + end) - message - end + message end - @spec discover_service(Message.t()) :: {:unavailable, Message.t()} | {node | :local, atom} - def discover_service(%Message{service_name: service} = message) do - case ServiceRegistry.find_nodes_with_service(service) do - :not_found -> {:unavailable, message} - {:local, {mod, _fun}} -> {:local, mod} - {:remote, nodes} -> Enum.random(nodes) + @spec discover_service(Message.t()) :: + :not_found + | [{:local | atom(), router_mod :: module()}] + def discover_service(%Message{service_name: service}) do + service + |> ServiceRegistry.find_nodes_with_service() + |> case do + :not_found -> :not_found + {:local, {mod, _fun}} -> [{:local, mod}] + {:remote, nodes} -> Enum.into(nodes, []) end end @@ -107,10 +118,37 @@ defmodule X3m.System.Dispatcher do defp _authorized?(node, mod, %Message{} = message), do: :rpc.call(node, mod, :authorized?, [message]) + defp _dispatch(node, mod, %Message{} = message, timeout, mono_start) do + Instrumenter.execute( + :service_found, + %{time: DateTime.utc_now(), duration: Instrumenter.duration(mono_start)}, + %{message: message, caller_node: Node.self(), service_node: node} + ) + + mono_start = System.monotonic_time() + + Instrumenter.execute( + :invoking_service, + %{start: DateTime.utc_now(), mono_start: mono_start}, + %{message: message, caller_node: Node.self(), service_node: node} + ) + + message = _dispatch(node, mod, message, timeout) + + Instrumenter.execute( + :service_responded, + %{time: DateTime.utc_now(), duration: Instrumenter.duration(mono_start)}, + %{message: message, caller_node: Node.self(), service_node: node} + ) + + message + end + defp _dispatch(:local, mod, %Message{} = message, timeout) do # if calling function does something with big binaries, they can leak if # caller process is long-lived (refc binary leaks) - spawn(fn -> + X3m.System.TaskSupervisor + |> Task.Supervisor.start_child(fn -> :ok = apply(mod, message.service_name, [message]) end) diff --git a/lib/message.ex b/lib/message.ex index 07d41dc..ab86d71 100644 --- a/lib/message.ex +++ b/lib/message.ex @@ -27,7 +27,6 @@ defmodule X3m.System.Message do * `halted?` - when set to `true` it means that response should be returned to the invoker without further processing of Message. """ - require Logger alias X3m.System.Response @enforce_keys ~w(service_name id correlation_id causation_id invoked_at dry_run diff --git a/lib/router.ex b/lib/router.ex index f2885d4..2c9936c 100644 --- a/lib/router.ex +++ b/lib/router.ex @@ -86,7 +86,6 @@ defmodule X3m.System.Router do ...> MyRouter.create_user() :ok """ - require Logger alias X3m.System.Message defmacro service(service_name, message_handler, f) do @@ -129,8 +128,8 @@ defmodule X3m.System.Router do origin_node: message.origin_node }) - message - |> authorize() + __MODULE__ + |> apply(:authorize, [message]) |> case do :ok -> message @@ -193,8 +192,8 @@ defmodule X3m.System.Router do origin_node: message.origin_node }) - message - |> authorize() + __MODULE__ + |> apply(:authorize, [message]) |> case do :ok -> message diff --git a/lib/scheduler.ex b/lib/scheduler.ex index 0aa0b7b..338f1e9 100644 --- a/lib/scheduler.ex +++ b/lib/scheduler.ex @@ -173,8 +173,8 @@ defmodule X3m.System.Scheduler do |> Message.assign(:dispatch_attempts, 0) msg = - msg - |> save_alarm(aggregate_id, state.client_state) + __MODULE__ + |> apply(:save_alarm, [msg, aggregate_id, state.client_state]) |> case do :ok -> msg {:ok, %Message{} = new_message} -> new_message diff --git a/lib/service_registry.ex b/lib/service_registry.ex index 0afd53d..9fc1a60 100644 --- a/lib/service_registry.ex +++ b/lib/service_registry.ex @@ -7,6 +7,13 @@ defmodule X3m.System.ServiceRegistry do def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, :ok, [{:name, __MODULE__} | opts]) + @doc """ + {:remote, + %{ + engine_1@localhost: Engine.X3m.Router, + engine_2@localhost: Engine.X3m.Router + }} + """ def find_nodes_with_service(service), do: GenServer.call(__MODULE__, {:find_nodes_with_service, service}) diff --git a/mix.exs b/mix.exs index 149d24f..c6fe258 100644 --- a/mix.exs +++ b/mix.exs @@ -34,7 +34,7 @@ defmodule X3m.System.MixProject do "coveralls.detail": :test, "coveralls.post": :test, "coveralls.html": :test, - dialyzer: :dev, + dialyzer: :test, bless: :test ] ] @@ -67,7 +67,7 @@ defmodule X3m.System.MixProject do defp _bless(_) do [ {"format", ["--check-formatted"]}, - # {"compile", ["--warnings-as-errors", "--force"]}, + {"compile", ["--warnings-as-errors", "--force"]}, {"coveralls.html", []}, {"dialyzer", []}, {"docs", []} diff --git a/test/dispatcher_test.exs b/test/dispatcher_test.exs index b9159af..bd35f6c 100644 --- a/test/dispatcher_test.exs +++ b/test/dispatcher_test.exs @@ -19,6 +19,13 @@ defmodule X3m.System.DispatcherTest do Dispatcher.dispatch(msg) end + test "node can't process request" do + msg = _new_message(:try_another_node) + + assert %Message{response: {:error, {:no_nodes_available, [local: :quorum_not_met]}}} = + Dispatcher.dispatch(msg) + end + test "invoke local service" do msg = _new_message(:first) assert %Message{response: {:ok, :from_first}} = Dispatcher.dispatch(msg) @@ -32,6 +39,7 @@ defmodule X3m.System.DispatcherTest do test "if service call is authorized" do assert Dispatcher.authorized?(_new_message(:first)) == true assert Dispatcher.authorized?(_new_message(:private_service)) == true + assert Dispatcher.authorized?(_new_message(:try_another_node)) == true assert Dispatcher.authorized?(_new_message(:unauthorized_service)) == false assert Dispatcher.authorized?(_new_message(:custom_unauthorized_service)) == false assert Dispatcher.authorized?(_new_message(:wrong_service)) == :service_unavailable diff --git a/test/router_test.exs b/test/router_test.exs index fdaeb5d..60a513b 100644 --- a/test/router_test.exs +++ b/test/router_test.exs @@ -5,7 +5,12 @@ defmodule X3m.System.RouterTest do describe "get registered services" do test "public" do - assert [first: 1, unauthorized_service: 1, custom_unauthorized_service: 1] = + assert [ + first: 1, + unauthorized_service: 1, + custom_unauthorized_service: 1, + try_another_node: 1 + ] = Router.registered_services(:public) end @@ -18,7 +23,8 @@ defmodule X3m.System.RouterTest do private_service: 1, first: 1, unauthorized_service: 1, - custom_unauthorized_service: 1 + custom_unauthorized_service: 1, + try_another_node: 1 ] = Router.registered_services(:all) end end diff --git a/test/scheduler_test.exs b/test/scheduler_test.exs index 5bffbcb..7c49352 100644 --- a/test/scheduler_test.exs +++ b/test/scheduler_test.exs @@ -1,6 +1,5 @@ defmodule X3m.System.SchedulerTest do use ExUnit.Case, async: true - require Logger alias X3m.System.Test.Scheduler alias X3m.System.Message diff --git a/test/support/router.ex b/test/support/router.ex index 5d8a0ea..ed2268f 100644 --- a/test/support/router.ex +++ b/test/support/router.ex @@ -10,6 +10,11 @@ defmodule X3m.System.Test.Controller do msg = Message.ok(msg, :from_private) {:reply, msg} end + + def try_another_node(%Message{} = msg) do + msg = Message.error(msg, {:try_another_node, :quorum_not_met}) + {:reply, msg} + end end defmodule X3m.System.Test.Router do @@ -21,11 +26,13 @@ defmodule X3m.System.Test.Router do service :first, Controller service :unauthorized_service, Controller, :first service :custom_unauthorized_service, Controller, :first + service :try_another_node, Controller servicep :private_service, Controller, :private def authorize(%SysMsg{service_name: :first}), do: :ok def authorize(%SysMsg{service_name: :private_service}), do: :ok + def authorize(%SysMsg{service_name: :try_another_node}), do: :ok def authorize(%SysMsg{service_name: :custom_unauthorized_service}), do: {:forbidden, "None shall pass!"} From b446ae3c0da30d384fe3bc19c8b02f891cb21d5f Mon Sep 17 00:00:00 2001 From: Burmaja Milan Date: Mon, 8 Jun 2026 14:26:01 +0100 Subject: [PATCH 2/9] Add distribution tests --- coveralls.json | 8 +++ lib/router.ex | 64 +++++++++++++++----- mix.exs | 8 ++- mix.lock | 2 + test/dispatcher_test.exs | 10 ++-- test/distributed_dispatch_test.exs | 56 ++++++++++++++++++ test/support/cluster_case.ex | 94 ++++++++++++++++++++++++++++++ test/support/controller.ex | 26 +++++++++ test/support/dist_router.ex | 17 ++++++ test/support/router.ex | 19 ------ test/test_helper.exs | 5 ++ 11 files changed, 267 insertions(+), 42 deletions(-) create mode 100644 coveralls.json create mode 100644 test/distributed_dispatch_test.exs create mode 100644 test/support/cluster_case.ex create mode 100644 test/support/controller.ex create mode 100644 test/support/dist_router.ex diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 0000000..90c3795 --- /dev/null +++ b/coveralls.json @@ -0,0 +1,8 @@ +{ + "coverage_options": { + "minimum_coverage": 37.0 + }, + "skip_files": [ + "test/support" + ] +} diff --git a/lib/router.ex b/lib/router.ex index 2c9936c..0659e63 100644 --- a/lib/router.ex +++ b/lib/router.ex @@ -398,22 +398,54 @@ defmodule X3m.System.Router do [module, logging_function] end - defmacro __before_compile__(_env) do - quote do - # Authorizes given `message`. Should return `:ok` if request is authorized, - # otherwise, response will be set as `Message.response` and will be returned to the caller - # immediately - # - # By default it returns `:forbidden` but it can/should be overridden - # at least for cases where service call should be processed. - # - # ``` - # def authorize(%X3m.System.Message{service_name: :example_service, assigns: %{identity: %{admin?: true}}}), - # do: :ok - # ``` - @spec authorize(Message.t()) :: :ok | :forbidden - def authorize(_sys_msg), - do: :forbidden + defmacro __before_compile__(env) do + # Only inject the deny-by-default `authorize/1` clause when the client hasn't already + # defined its own catch-all. Otherwise our clause would be redundant and the compiler + # would warn (which fails builds run with `--warnings-as-errors`). + if _catch_all_authorize?(env.module) do + nil + else + quote do + # Authorizes given `message`. Should return `:ok` if request is authorized, + # otherwise, response will be set as `Message.response` and will be returned to the caller + # immediately + # + # By default it returns `:forbidden` but it can/should be overridden + # at least for cases where service call should be processed. + # + # ``` + # def authorize(%X3m.System.Message{service_name: :example_service, assigns: %{identity: %{admin?: true}}}), + # do: :ok + # ``` + @spec authorize(Message.t()) :: :ok | :forbidden + def authorize(_sys_msg), + do: :forbidden + end end end + + # coveralls-ignore-start + # These run only at compile time (during `__before_compile__` expansion), so the runtime + # coverage tool never exercises them; their behavior is covered by routers compiling with + # and without a catch-all `authorize/1`. + + # Returns `true` if `module` already defines an unconditional catch-all `authorize/1` + # clause (a bare variable or `_` with no guard). + defp _catch_all_authorize?(module) do + if Module.defines?(module, {:authorize, 1}) do + {_version, _kind, _meta, clauses} = Module.get_definition(module, {:authorize, 1}) + Enum.any?(clauses, &_catch_all_clause?/1) + else + false + end + end + + defp _catch_all_clause?({_meta, [{name, _var_meta, context}], [] = _guards, _body}) + when is_atom(name) and is_atom(context), + do: true + + defp _catch_all_clause?(_clause), + do: false + + # coveralls-ignore-stop end diff --git a/mix.exs b/mix.exs index c6fe258..c682d42 100644 --- a/mix.exs +++ b/mix.exs @@ -4,11 +4,11 @@ defmodule X3m.System.MixProject do def project do [ app: :x3m_system, - version: "0.9.0", - elixir: "~> 1.11", + version: "0.9.1", + elixir: "~> 1.17", source_url: "https://github.com/x3m-ex/system", description: """ - Building blocks for distributed systems + Building blocks for distributed and/or CQRS/ES systems """, package: _package(), start_permanent: true, @@ -16,6 +16,7 @@ defmodule X3m.System.MixProject do name: "X3m System", aliases: _aliases(), deps: _deps(), + dialyzer: [plt_add_apps: [:ex_unit, :local_cluster]], elixirc_paths: _elixirc_paths(Mix.env()) ] end @@ -52,6 +53,7 @@ defmodule X3m.System.MixProject do {:tzdata, "~> 1.0", optional: true}, # test dependencies + {:local_cluster, "~> 2.0", only: [:test], runtime: false}, {:dialyxir, "~> 1.1", only: [:test, :dev], runtime: false}, {:ex_doc, "~> 0.21", only: [:dev, :test], runtime: false}, {:excoveralls, "~> 0.13", only: :test} diff --git a/mix.lock b/mix.lock index 893e433..5a038f1 100644 --- a/mix.lock +++ b/mix.lock @@ -6,9 +6,11 @@ "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, + "global_flags": {:hex, :global_flags, "1.0.0", "ee6b864979a1fb38d1fbc67838565644baf632212bce864adca21042df036433", [:rebar3], [], "hexpm", "85d944cecd0f8f96b20ce70b5b16ebccedfcd25e744376b131e89ce61ba93176"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "local_cluster": {:hex, :local_cluster, "2.1.0", "1c847d69a927ef5a62db13236f93146e8a42377a9c9a5bb4cac3372cba69d683", [:mix], [{:global_flags, "~> 1.0", [hex: :global_flags, repo: "hexpm", optional: false]}], "hexpm", "dc1c3abb6fef00198dd53c855b39ea80c55b3a8059d8d9f17d50da46b1e3b858"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, diff --git a/test/dispatcher_test.exs b/test/dispatcher_test.exs index bd35f6c..62364b2 100644 --- a/test/dispatcher_test.exs +++ b/test/dispatcher_test.exs @@ -223,27 +223,29 @@ defmodule X3m.System.DispatcherTest do end def telemetry_handler(service_name, parent, ref) do + caller_node = Node.self() + fn [:x3m, :system, :discovering_service], _measurements, meta, _config -> - assert %{caller_node: :nonode@nohost, message: %Message{service_name: ^service_name}} = + assert %{caller_node: ^caller_node, message: %Message{service_name: ^service_name}} = meta send(parent, {ref, :discovering_service}) [:x3m, :system, :service_not_found], _measurements, meta, _config -> - assert %{caller_node: :nonode@nohost, message: %Message{service_name: ^service_name}} = + assert %{caller_node: ^caller_node, message: %Message{service_name: ^service_name}} = meta send(parent, {ref, :service_not_found}) [:x3m, :system, :service_found], _measurements, meta, _config -> - assert %{caller_node: :nonode@nohost, message: %Message{service_name: ^service_name}} = + assert %{caller_node: ^caller_node, message: %Message{service_name: ^service_name}} = meta send(parent, {ref, :service_found}) [:x3m, :system, :checking_if_service_call_is_authorized], _measurements, meta, _config -> - assert %{caller_node: :nonode@nohost, message: %Message{service_name: ^service_name}} = + assert %{caller_node: ^caller_node, message: %Message{service_name: ^service_name}} = meta send(parent, {ref, :checking_if_service_call_is_authorized}) diff --git a/test/distributed_dispatch_test.exs b/test/distributed_dispatch_test.exs new file mode 100644 index 0000000..2c5b616 --- /dev/null +++ b/test/distributed_dispatch_test.exs @@ -0,0 +1,56 @@ +defmodule X3m.System.DistributedDispatchTest do + use ExUnit.Case, async: false + + alias X3m.System.ClusterCase + alias X3m.System.Dispatcher + alias X3m.System.Message + alias X3m.System.Test.DistRouter + + # These services are hosted only on the peer nodes, never on this (manager) node, so the + # exact same `Dispatcher.dispatch(msg)` call transparently routes across the cluster. + + test "dispatches to a remote node and the reply crosses back" do + [server] = ClusterCase.start_nodes(1) + + ClusterCase.register_router(server, DistRouter) + ClusterCase.wait_until_discovered(:remote_first, [server]) + + msg = Message.new(:remote_first) + + assert %Message{response: {:ok, :from_first}} = Dispatcher.dispatch(msg) + end + + test "when every remote node asks for another node, no node is available" do + [server_1, server_2] = ClusterCase.start_nodes(2) + + ClusterCase.register_router(server_1, DistRouter) + ClusterCase.register_router(server_2, DistRouter) + ClusterCase.put_node_env(server_1, :reject_quorum?, true) + ClusterCase.put_node_env(server_2, :reject_quorum?, true) + ClusterCase.wait_until_discovered(:maybe_another_node, [server_1, server_2]) + + msg = Message.new(:maybe_another_node) + + assert %Message{response: {:error, {:no_nodes_available, tried_nodes}}} = + Dispatcher.dispatch(msg) + + assert Enum.sort([ + {server_1, :quorum_not_met}, + {server_2, :quorum_not_met} + ]) == Enum.sort(tried_nodes) + end + + test "when one remote node asks for another node, the responding node is used" do + [server_1, server_2] = ClusterCase.start_nodes(2) + + ClusterCase.register_router(server_1, DistRouter) + ClusterCase.register_router(server_2, DistRouter) + ClusterCase.put_node_env(server_1, :reject_quorum?, true) + ClusterCase.put_node_env(server_2, :reject_quorum?, false) + ClusterCase.wait_until_discovered(:maybe_another_node, [server_1, server_2]) + + msg = Message.new(:maybe_another_node) + + assert %Message{response: {:ok, :from_quorum}} = Dispatcher.dispatch(msg) + end +end diff --git a/test/support/cluster_case.ex b/test/support/cluster_case.ex new file mode 100644 index 0000000..ceb21f5 --- /dev/null +++ b/test/support/cluster_case.ex @@ -0,0 +1,94 @@ +defmodule X3m.System.ClusterCase do + @moduledoc """ + Helpers for the distributed Dispatcher → Router tests. + + Spins up real peer nodes with `LocalCluster`, each running the `:x3m_system` + application, and offers small wrappers around `:rpc` for registering routers, + setting per-node config, waiting for service discovery to propagate, and + dispatching from a node that does not host the service (so the call really + crosses the node boundary). + """ + import ExUnit.Callbacks, only: [on_exit: 1] + + alias X3m.System.ServiceRegistry + + @doc """ + Starts `count` peer nodes running `:x3m_system` and returns their node names. + + The cluster is stopped automatically when the calling test finishes. + """ + @spec start_nodes(count :: pos_integer(), opts :: Keyword.t()) :: [node()] + def start_nodes(count, opts \\ []) do + # A unique prefix per cluster keeps node names from colliding across tests, so a dead + # node's async cleanup can never race with a same-named node in a later test. + opts = Keyword.put_new(opts, :applications, [:x3m_system]) + {:ok, cluster} = LocalCluster.start_link(count, opts) + {:ok, nodes} = LocalCluster.nodes(cluster) + + on_exit(fn -> + if Process.alive?(cluster), do: LocalCluster.stop(cluster) + # The async `:node_left` cleanup doesn't reliably run within the fast teardown between + # tests, so explicitly drop these nodes from this (manager) node's registry. This keeps + # tests independent — a later test never discovers a previous test's dead nodes. + Enum.each(nodes, fn node -> send(ServiceRegistry, {:unregister_node_services, node}) end) + end) + + nodes + end + + @doc "Registers `router`'s services on `node`, making `node` a provider for them." + @spec register_router(node(), router :: module()) :: :ok + def register_router(node, router), + do: :ok = :rpc.call(node, router, :register_services, []) + + @doc "Sets `:x3m_system` application env `key` to `value` on `node` only." + @spec put_node_env(node(), key :: atom(), value :: term()) :: :ok + def put_node_env(node, key, value), + do: :ok = :rpc.call(node, Application, :put_env, [:x3m_system, key, value]) + + @doc """ + Waits until the local (manager) node has discovered `service` as remote, provided by + exactly `expected_nodes`. Registration auto-broadcasts to connected peers, but + propagation — and cleanup of dead nodes from earlier tests — is async, so we poll until + the provider set matches exactly. Matching exactly guarantees a later `Dispatcher.dispatch/1` + never tries a stale, dead node. + """ + @spec wait_until_discovered( + service :: atom(), + expected_nodes :: [node()], + timeout :: non_neg_integer() + ) :: :ok + def wait_until_discovered(service, expected_nodes, timeout \\ 2_000) do + expected = Enum.sort(expected_nodes) + deadline = System.monotonic_time(:millisecond) + timeout + _wait_until_discovered(service, expected, deadline) + end + + defp _wait_until_discovered(service, expected, deadline) do + service + |> ServiceRegistry.find_nodes_with_service() + |> case do + {:remote, nodes} -> + if Enum.sort(Map.keys(nodes)) == expected do + :ok + else + _retry_discovery(service, expected, deadline) + end + + _other -> + _retry_discovery(service, expected, deadline) + end + end + + defp _retry_discovery(service, expected, deadline) do + if System.monotonic_time(:millisecond) >= deadline do + saw = ServiceRegistry.find_nodes_with_service(service) + + raise "service #{inspect(service)} not discovered as provided by " <> + "#{inspect(expected)} before timeout; saw #{inspect(saw)}" + end + + Process.sleep(50) + _wait_until_discovered(service, expected, deadline) + end +end diff --git a/test/support/controller.ex b/test/support/controller.ex new file mode 100644 index 0000000..4063176 --- /dev/null +++ b/test/support/controller.ex @@ -0,0 +1,26 @@ +defmodule X3m.System.Test.Controller do + alias X3m.System.Message + + def first(%Message{} = msg) do + msg = Message.ok(msg, :from_first) + {:reply, msg} + end + + def private(%Message{} = msg) do + msg = Message.ok(msg, :from_private) + {:reply, msg} + end + + def try_another_node(%Message{} = msg) do + msg = Message.error(msg, {:try_another_node, :quorum_not_met}) + {:reply, msg} + end + + # Rejects or responds based on the *node-local* `:reject_quorum?` app env, so each + # peer in a cluster can be told independently whether to ask for another node. + def maybe_another_node(%Message{} = msg) do + if Application.get_env(:x3m_system, :reject_quorum?, false), + do: {:reply, Message.error(msg, {:try_another_node, :quorum_not_met})}, + else: {:reply, Message.ok(msg, :from_quorum)} + end +end diff --git a/test/support/dist_router.ex b/test/support/dist_router.ex new file mode 100644 index 0000000..6940976 --- /dev/null +++ b/test/support/dist_router.ex @@ -0,0 +1,17 @@ +defmodule X3m.System.Test.DistRouter do + @moduledoc !""" + Router used only by the distributed tests. It is intentionally NOT registered in + `test_helper.exs`, so the manager node never hosts these services. Each peer that should + host them calls `register_services/0` (via rpc), keeping the set of providers a client + node discovers limited to exactly the peers under test. + """ + + use X3m.System.Router + + alias X3m.System.Test.Controller + + service :remote_first, Controller, :first + service :maybe_another_node, Controller + + def authorize(_), do: :ok +end diff --git a/test/support/router.ex b/test/support/router.ex index ed2268f..12149b9 100644 --- a/test/support/router.ex +++ b/test/support/router.ex @@ -1,22 +1,3 @@ -defmodule X3m.System.Test.Controller do - alias X3m.System.Message - - def first(%Message{} = msg) do - msg = Message.ok(msg, :from_first) - {:reply, msg} - end - - def private(%Message{} = msg) do - msg = Message.ok(msg, :from_private) - {:reply, msg} - end - - def try_another_node(%Message{} = msg) do - msg = Message.error(msg, {:try_another_node, :quorum_not_met}) - {:reply, msg} - end -end - defmodule X3m.System.Test.Router do use X3m.System.Router diff --git a/test/test_helper.exs b/test/test_helper.exs index 77591d1..e11ce8f 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,8 @@ +# Start the current node in distributed mode once, so the whole suite runs under a +# named node (`:manager@127.0.0.1`). This keeps node-dependent assertions deterministic +# regardless of test ordering and lets the distributed tests spin up peer nodes. +:ok = LocalCluster.start() + ExUnit.start() :ok = X3m.System.Test.Router.register_services() From f6886f926c346b00c0972d318d7335eb6752a597 Mon Sep 17 00:00:00 2001 From: Burmaja Milan Date: Tue, 9 Jun 2026 09:55:24 +0100 Subject: [PATCH 3/9] Improve documentation --- .github/workflows/bless.yml | 4 +- CHANGELOG.md | 4 +- README.md | 69 ++++++- guides/aggregates-and-event-sourcing.md | 248 ++++++++++++++++++++++++ guides/distribution.md | 117 +++++++++++ guides/getting-started.md | 99 ++++++++++ guides/messaging.md | 138 +++++++++++++ guides/scheduling.md | 115 +++++++++++ lib/aggregate.ex | 79 +++++++- lib/aggregate_group.ex | 4 + lib/aggregate_pid_facade.ex | 4 + lib/aggregate_pid_manager.ex | 4 + lib/aggregate_registry.ex | 7 +- lib/aggregate_repo.ex | 55 +++++- lib/aggregate_sup.ex | 4 + lib/application.ex | 5 +- lib/dispatcher.ex | 53 +++++ lib/gen_aggregate.ex | 6 + lib/gen_aggregate_mod.ex | 4 + lib/instrumenter.ex | 4 + lib/local_aggregates.ex | 13 ++ lib/local_aggregates_supervision.ex | 10 + lib/message.ex | 39 +++- lib/message_handler.ex | 53 +++++ lib/node_monitor.ex | 4 + lib/response.ex | 149 ++++++++++++-- lib/router.ex | 14 +- lib/scheduler.ex | 1 + lib/service_registry.ex | 5 + lib/service_registry/implementation.ex | 5 +- lib/service_registry/state.ex | 5 + lib/service_telemetry_handler.ex | 4 + mix.exs | 61 +++++- test/message_test.exs | 4 + test/response_test.exs | 4 + 35 files changed, 1354 insertions(+), 40 deletions(-) create mode 100644 guides/aggregates-and-event-sourcing.md create mode 100644 guides/distribution.md create mode 100644 guides/getting-started.md create mode 100644 guides/messaging.md create mode 100644 guides/scheduling.md create mode 100644 test/message_test.exs create mode 100644 test/response_test.exs diff --git a/.github/workflows/bless.yml b/.github/workflows/bless.yml index 5485386..b2785bf 100644 --- a/.github/workflows/bless.yml +++ b/.github/workflows/bless.yml @@ -54,4 +54,6 @@ jobs: mix deps.get - name: Full bless - run: mix bless + run: | + epmd -daemon + mix bless diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c2d546..6715f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,8 +79,8 @@ use X3m.System.MessageHandler, # 0.7.16 -- `execute_on_new_aggregate returns `{:ok, -1}`if aggregate returns`:ok`response -with empty`events` +- `execute_on_new_aggregate` returns `{:ok, -1}` if aggregate returns `:ok` response + with empty `events` # 0.7.15 diff --git a/README.md b/README.md index 6b1b077..fe209cc 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,79 @@ [![Hex version](https://img.shields.io/hexpm/v/x3m_system.svg "Hex version")](https://hex.pm/packages/x3m_system) [![Coverage Status](https://coveralls.io/repos/github/x3m-ex/system/badge.svg)](https://coveralls.io/github/x3m-ex/system) -Infrastructure building blocks for X3m backend system. +Building blocks for distributed and/or CQRS/ES systems in Elixir. + +`X3m.System` gives you a small set of composable pieces for building message-driven +backends: a **message** that carries a request and its response, a **router** that +registers services across a cluster, a **dispatcher** that finds a node offering a +service and waits for the reply, and — when you need it — **aggregates** with event +sourcing and a backend-agnostic **scheduler** for delivering messages in the future. + +The pieces are **à la carte**. You can use the messaging layer (message + router + +dispatcher) on its own, add aggregates and event sourcing only where you need them, +and use the scheduler independently of everything else. ## Installation ```elixir def deps do [ - {:x3m_system, "~> 0.7.10"}} + {:x3m_system, "~> 0.9.1"} ] end ``` + +Two dependencies are optional and only needed for some building blocks: + +- `:elixir_uuid` — needed when working with aggregates (id generation). +- `:tzdata` — needed for `X3m.System.Scheduler`. + +## A minimal example + +Define a router that registers a service and the module that handles it: + +```elixir +defmodule MyApp.Router do + use X3m.System.Router + + service :greet, MyApp.Greeter + + def authorize(_message), do: :ok +end + +defmodule MyApp.Greeter do + alias X3m.System.Message + + def greet(%Message{} = message) do + name = message.raw_request["name"] + {:reply, Message.ok(message, "Hello, #{name}!")} + end +end +``` + +Register the services (typically from your application's `start/2`) and dispatch a +message to the service by name: + +```elixir +:ok = MyApp.Router.register_services() + +:greet +|> X3m.System.Message.new(raw_request: %{"name" => "Ada"}) +|> X3m.System.Dispatcher.dispatch() +#=> %X3m.System.Message{response: {:ok, "Hello, Ada!"}, ...} +``` + +No aggregates or event store are involved here — any module registered through a +router can be a dispatch target. + +## Guides + +- [Getting started](guides/getting-started.md) — install, optional deps, your first dispatch. +- [Messaging](guides/messaging.md) — `Message`, `Router`, `Dispatcher` and the response shapes. +- [Aggregates & event sourcing](guides/aggregates-and-event-sourcing.md) — `Aggregate`, `MessageHandler`, persisting events, snapshotting and supervision. +- [Distribution](guides/distribution.md) — service discovery across nodes, choosing the node, and forwarding. +- [Scheduling](guides/scheduling.md) — persistable, future-dated message delivery with `Scheduler`. + +## License + +Released under the MIT License. See the [LICENSE](https://github.com/x3m-ex/system/blob/master/LICENSE) file. diff --git a/guides/aggregates-and-event-sourcing.md b/guides/aggregates-and-event-sourcing.md new file mode 100644 index 0000000..2c4b93d --- /dev/null +++ b/guides/aggregates-and-event-sourcing.md @@ -0,0 +1,248 @@ +# Aggregates & event sourcing + +An **aggregate** is a consistency boundary: a single entity (an account, an order, a +user) that decides whether a command is allowed and what should happen as a result. +In `X3m.System` an aggregate is **event-sourced** by default — its state is not stored +directly but rebuilt by replaying the events it has produced. (You can also persist +state directly instead; see [State persistence](#state-persistence-event-sourcing-or-not).) + +This guide builds a small bank-account aggregate. It assumes you're comfortable with +the [messaging layer](messaging.md); aggregates plug into the same routers and +dispatcher. + +## The moving parts + +| Piece | Responsibility | +|---|---| +| `X3m.System.Aggregate` | decides how a command is handled and how events change state | +| `X3m.System.MessageHandler` | loads the aggregate, runs the command, persists events, replies | +| `X3m.System.Aggregate.Repo` | reads and writes the event stream (you implement it) | +| `X3m.System.Router` | routes a service call to the message handler | + +## 1. The aggregate + +`use X3m.System.Aggregate` and declare the starting state, one command handler per +command, and one `apply_event/2` clause per event: + +```elixir +defmodule MyApp.Accounts.Aggregate do + use X3m.System.Aggregate + alias X3m.System.Message, as: SysMsg + alias MyApp.Accounts.{Commands, Events, State} + + @impl X3m.System.Aggregate + def initial_state, do: %State{} + + handle_msg :open_account, &Commands.Open.new/2, &handle_open/2 + handle_msg :deposit, &Commands.Deposit.new/2, &handle_deposit/2 + + defp handle_open(%SysMsg{request: %Commands.Open{} = cmd} = msg, %State{status: :new} = state) do + event = %Events.Opened{id: cmd.id, owner: cmd.owner} + + msg = + msg + |> SysMsg.add_event(event) + |> SysMsg.created(cmd.id) + + {:block, msg, state} + end + + defp handle_deposit(%SysMsg{request: %Commands.Deposit{} = cmd} = msg, %State{} = state) do + event = %Events.Deposited{id: state.id, amount: cmd.amount} + + msg = + msg + |> SysMsg.add_event(event) + |> SysMsg.ok() + + {:block, msg, state} + end + + def apply_event(%Events.Opened{} = e, %State{} = state), + do: %State{state | id: e.id, owner: e.owner, status: :open} + + def apply_event(%Events.Deposited{} = e, %State{} = state), + do: %State{state | balance: state.balance + e.amount} +end +``` + +### Command handlers and the block/noblock contract + +`handle_msg/3` takes a **validate** function and a **process** function: + +- The validate function (`Commands.Open.new/2`) casts `message.raw_request` into a + structured request and stores it with `X3m.System.Message.put_request/2`. If the + request is invalid, `put_request/2` halts the message with a `:validation_error` and + the process function never runs. +- The process function returns one of: + - `{:block, message, state}` — there are events to persist. The message handler saves + `message.events`, commits, and only then replies. + - `{:noblock, message, state}` — nothing to persist; the response is returned as-is + (e.g. an idempotent no-op or a rejected command). + +A validate function looks like this (using a plain struct here; an `Ecto.Changeset` +works too, since `put_request/2` checks `valid?`): + +```elixir +defmodule MyApp.Accounts.Commands.Open do + defstruct [:id, :owner, valid?: true] + + alias X3m.System.Message, as: SysMsg + + def new(%SysMsg{raw_request: %{"id" => id, "owner" => owner}} = msg, _state) do + SysMsg.put_request(%__MODULE__{id: id, owner: owner}, msg) + end +end +``` + +Use the two-argument `handle_msg/2` when a command needs no separate validation step — +pass a single function that returns the `{:block | :noblock, message, state}` tuple. + +### Applying events + +`apply_event/2` is the only place state changes. It runs both when a command produces a +new event and when the aggregate is rehydrated from history, so it must be pure. Events +your aggregate doesn't recognise fall through to a default clause that leaves state +unchanged. + +### Idempotency + +The macros skip a command whose `message.id` was already processed, returning `:ok` +without re-running it. Override `processed_message_id/1` to pull the originating id out +of your event metadata so this survives restarts. + +## 2. The message handler + +The message handler wires the aggregate to a persistence backend and exposes one +function per command: + +```elixir +defmodule MyApp.Accounts.MessageHandler do + use X3m.System.MessageHandler, + aggregate_mod: MyApp.Accounts.Aggregate, + aggregate_repo: MyApp.Accounts.AggregateRepo, + stream: "accounts", + pid_facade_mod: X3m.System.AggregatePidFacade, + event_metadata: %{app_version: "1.0.0"} + + on_new_aggregate :open_account # id read from raw_request["id"] + on_aggregate :deposit, id: "account_id" # id read from raw_request["account_id"] +end +``` + +Pick the macro that matches the command's intent: + +- `on_new_aggregate` — creates a fresh aggregate. The id may be generated if missing. +- `on_aggregate` — loads an existing aggregate; responds `{:error, :not_found}` if there + is no stream for that id. +- `on_maybe_new_aggregate` — loads the aggregate, creating it if it doesn't exist yet. + +Each macro reads the aggregate id from `message.raw_request` (under `"id"` by default, +or the `:id` option), loads or spawns the aggregate process, runs the command, persists +any events, and enriches the response with the new version — `{:ok, version}` or +`{:created, id, version}`. + +`:event_metadata` is merged into the metadata stored with every event (handy for +schema/app versions). `:commit_timeout` (per macro) bounds how long persistence may +take. + +## 3. The event store (`Aggregate.Repo`) + +`X3m.System` does not ship a concrete store — you implement `X3m.System.Aggregate.Repo` +against whatever you use (EventStoreDB, Postgres, an in-memory store for tests): + +```elixir +defmodule MyApp.Accounts.AggregateRepo do + use X3m.System.Aggregate.Repo + + @impl true + def has?(stream_name), do: # ... + + @impl true + def stream_events(stream_name, start_at, per_page), do: # ... enumerable of {event, number, metadata} + + @impl true + def delete_stream(stream_name, hard_delete?, expected_version), do: :ok + + @impl true + def save_events(stream_name, message, events_metadata), do: {:ok, _last_event_number} +end +``` + +Stream names are built from the handler's `:stream` option and the aggregate id, e.g. +`"accounts-acc-1"`. + +## 4. Wiring it into your app + +Register the router's services, and start the per-node aggregate processes by adding +`X3m.System.LocalAggregatesSupervision` to your supervision tree. First, list the +aggregate modules to run locally: + +```elixir +defmodule MyApp.LocalAggregates do + use X3m.System.LocalAggregates, [MyApp.Accounts.Aggregate] +end +``` + +Then in your application: + +```elixir +def start(_type, _args) do + :ok = MyApp.Router.register_services() + + children = [ + MyApp.Accounts.AggregateRepo, + {X3m.System.LocalAggregatesSupervision, [MyApp.LocalAggregates, MyApp]} + ] + + Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) +end +``` + +Now a dispatch flows end to end: + +```elixir +:open_account +|> X3m.System.Message.new(raw_request: %{"id" => "acc-1", "owner" => "Ada"}) +|> X3m.System.Dispatcher.dispatch() +#=> %X3m.System.Message{response: {:created, "acc-1", 0}, ...} +``` + +## Validating without persisting (`dry_run`) + +`X3m.System.Dispatcher.validate/1` dispatches the command with `dry_run: true`: the +aggregate runs the command exactly as it would normally, but the message handler does +**not** persist any events and the aggregate's `rollback/2` callback is invoked so it +can undo any side effects. The response tells you whether the command *would* have +succeeded. If a command performs side effects during handling (e.g. reserving a unique +value), implement the `commit/2` and `rollback/2` callbacks on the aggregate. + +## State persistence: event sourcing or not + +Event sourcing is the default, but how an aggregate's state is saved and restored is +governed by two overridable message-handler callbacks: + +- `save_state/3` — called after each successful commit. Override it to persist the + aggregate's state. +- `when_pid_is_not_registered/3` — called when a command targets an aggregate whose + process isn't currently running. Override it to decide how that process is hydrated. + +Together they support a spectrum: + +- **Pure event sourcing (default):** `when_pid_is_not_registered/3` replays the event + stream through `apply_event/2`; `save_state/3` does nothing. +- **Snapshots on top of events:** `save_state/3` stores a periodic snapshot; + `when_pid_is_not_registered/3` loads the latest snapshot and replays only the events + after it. +- **Non-event-sourced (state-based) aggregates:** `save_state/3` persists the full + current state and `when_pid_is_not_registered/3` loads it directly — no event replay + at all. + +Your command logic (`handle_msg`) is identical in all three cases; only how state is +saved and restored differs. + +To unload idle aggregates and reclaim memory, pass `:unload_aggregate_on` to +`use X3m.System.MessageHandler` with rules keyed on emitted events or current state. + +When several nodes can host the same aggregate, see [Distribution](distribution.md) for +how a request is routed to the node where the aggregate already lives. diff --git a/guides/distribution.md b/guides/distribution.md new file mode 100644 index 0000000..8d1031b --- /dev/null +++ b/guides/distribution.md @@ -0,0 +1,117 @@ +# Distribution + +`X3m.System` is built for clusters. The same `X3m.System.Dispatcher.dispatch/2` call +works whether the service runs on the local node or a remote one — discovery and the +remote call are handled for you. This guide covers how nodes find each other's +services, how a provider is chosen, and how to forward work to a specific node. + +## How discovery works + +When a router calls `register_services/0`, its **public** services are announced to the +other connected nodes, and nodes exchange their service maps as they join and leave the +cluster. The dispatcher asks this registry which nodes offer `message.service_name`: + +- a **local** provider is invoked directly in a supervised task; +- a **remote** provider is invoked over `:rpc`, and the provider sends the reply + straight back to the caller's process. + +If no node offers the service, the response is `{:service_unavailable, service_name}`. + +## Public vs private services + +In a router, choose how widely a service is advertised: + +```elixir +defmodule MyApp.Router do + use X3m.System.Router + + service :open_account, MyApp.Accounts.MessageHandler # public: announced cluster-wide + servicep :rebuild_projection, MyApp.Projections # private: local node only + + def authorize(_), do: :ok +end +``` + +Public services (`service`) participate in cluster discovery, so any node can dispatch +to them. Private services (`servicep`) are only callable on the node that registered +them and are never advertised to peers — useful for node-local maintenance work. + +## Choosing the node: forwarding to where the aggregate lives + +When several nodes can host the same aggregate, you usually want a command to run on the +node where that aggregate is already in memory, rather than spinning it up elsewhere. +The router's `choose_node/1` callback decides this. It defaults to `:local`; override it +to return the `node()` that should handle the message: + +```elixir +defmodule MyApp.Router do + use X3m.System.Router + + service :deposit, MyApp.Accounts.MessageHandler + + def authorize(_), do: :ok + + # Look the aggregate up in a distributed registry (e.g. Horde) and run the command + # on the node that currently owns it. + def choose_node(%X3m.System.Message{raw_request: %{"account_id" => id}}) do + case Horde.Registry.lookup(MyApp.AggregateRegistry, id) do + [{_pid, node}] -> node + _ -> :local + end + end + + def choose_node(_message), do: :local +end +``` + +When `choose_node/1` returns a remote node, the router forwards the call there; that +node runs the handler and replies **directly** to the original caller — the response +does not hop back through the node that received the request. + +## Asking for another node + +Sometimes a node accepts a call but then realises it can't serve it (for example, a +quorum isn't met). It can tell the dispatcher to try a different provider by responding +with `{:error, {:try_another_node, reason}}`: + +```elixir +def deposit(%X3m.System.Message{} = msg) do + if quorum_met?() do + {:reply, handle(msg)} + else + {:reply, X3m.System.Message.error(msg, {:try_another_node, :quorum_not_met})} + end +end +``` + +The dispatcher then tries the next node offering the service. If every provider asks to +try another node, the response becomes `{:error, {:no_nodes_available, nodes}}`, where +`nodes` lists each node and the reason it gave. + +## Logging across nodes + +By default a router ensures that log output produced while handling a *remote* call +stays on the node doing the work, rather than leaking into the caller's stdout. When +driving services from an `iex` session it can be handy to see that output locally — +pass `ensure_local_logging?: false`: + +```elixir +defmodule MyApp.Router do + use X3m.System.Router, ensure_local_logging?: false + # ... +end +``` + +## Calling one service from another + +Services frequently call other services. Build the child message with +`X3m.System.Message.new_caused_by/3` so correlation and causation ids are preserved +across the hop, then dispatch as usual: + +```elixir +:get_owner_details +|> X3m.System.Message.new_caused_by(msg, raw_request: %{"owner_id" => owner_id}) +|> X3m.System.Dispatcher.dispatch() +``` + +This works the same whether the target service is local or on another node. diff --git a/guides/getting-started.md b/guides/getting-started.md new file mode 100644 index 0000000..e6995bf --- /dev/null +++ b/guides/getting-started.md @@ -0,0 +1,99 @@ +# Getting started + +`X3m.System` is a set of building blocks for message-driven Elixir backends. This +guide gets you from an empty project to dispatching your first message. It uses only +the messaging layer — no aggregates or event store required. + +## Install + +Add the dependency: + +```elixir +def deps do + [ + {:x3m_system, "~> 0.9"} + ] +end +``` + +Two dependencies are optional and pulled in only for specific building blocks: + +- `:elixir_uuid` — id generation when working with [aggregates](aggregates-and-event-sourcing.md). +- `:tzdata` — required by the [scheduler](scheduling.md). + +`X3m.System` starts its own OTP application (a task supervisor, a node monitor and the +service registry), so once it is a dependency there is nothing to add to your +supervision tree to use the messaging layer. + +## The three core pieces + +- `X3m.System.Message` — the struct that flows through the system. It carries the + service name, the raw request, shared `assigns`, and — once handled — a `response`. +- `X3m.System.Router` — registers **services** (named entry points) and maps each to a + module/function that handles it. +- `X3m.System.Dispatcher` — given a message, finds a node offering its service, invokes + it, and returns the message with its `response` set. + +## Your first service + +Define a router and a handler module: + +```elixir +defmodule MyApp.Router do + use X3m.System.Router + + service :greet, MyApp.Greeter + + def authorize(_message), do: :ok +end + +defmodule MyApp.Greeter do + alias X3m.System.Message + + def greet(%Message{} = message) do + name = message.raw_request["name"] + {:reply, Message.ok(message, "Hello, #{name}!")} + end +end +``` + +A few things to note: + +- `service :greet, MyApp.Greeter` registers the `:greet` service and routes it to + `MyApp.Greeter.greet/1` (same name). Use the three-argument form + `service :greet, MyApp.Greeter, :handle_greet` to call a differently named function. +- A handler returns `{:reply, message}` to send the message back to the caller, or + `:noreply` to stay silent. +- `authorize/1` runs before the handler. Returning `:ok` lets the call through; + anything else becomes the response. The default (if you don't define it) denies + everything, so you always define it explicitly. + +## Register and dispatch + +Services are announced to the registry at runtime — usually from your application's +`start/2`: + +```elixir +:ok = MyApp.Router.register_services() +``` + +Then build a message and dispatch it: + +```elixir +:greet +|> X3m.System.Message.new(raw_request: %{"name" => "Ada"}) +|> X3m.System.Dispatcher.dispatch() +#=> %X3m.System.Message{response: {:ok, "Hello, Ada!"}, ...} +``` + +`dispatch/2` blocks until the handler replies (default timeout 5s) and returns the +resolved message. Pattern-match on its `response` — see `X3m.System.Response` for the +full vocabulary of response shapes. + +## Where to go next + +- [Messaging](messaging.md) — the full message lifecycle, routers and responses. +- [Aggregates & event sourcing](aggregates-and-event-sourcing.md) — model state with + events. +- [Distribution](distribution.md) — run services across a cluster. +- [Scheduling](scheduling.md) — deliver messages in the future. diff --git a/guides/messaging.md b/guides/messaging.md new file mode 100644 index 0000000..0da925f --- /dev/null +++ b/guides/messaging.md @@ -0,0 +1,138 @@ +# Messaging + +The messaging layer — `X3m.System.Message`, `X3m.System.Router` and +`X3m.System.Dispatcher` — is the foundation everything else builds on. It works on its +own: you do not need aggregates or an event store to use it. + +## The message + +`X3m.System.Message` is the envelope that travels from the caller to a service and +back. You create one with `X3m.System.Message.new/2`: + +```elixir +alias X3m.System.Message, as: SysMsg + +msg = SysMsg.new(:open_account, raw_request: %{"id" => "acc-1", "owner" => "Ada"}) +``` + +Useful fields: + +- `service_name` — which service should handle it (`:open_account` above). +- `raw_request` — the request as received (e.g. controller `params`). +- `request` — a validated/structured request, set with `put_request/2` once you've + cast `raw_request` into a changeset or struct. +- `assigns` — a map for values you want to carry alongside the request; set with + `assign/3` (e.g. the authenticated user). +- `response` — set by the handler, read by the caller. +- `correlation_id` / `causation_id` — ids that tie a conversation together (see below). + +```elixir +msg = SysMsg.assign(msg, :invoked_by, current_user) +``` + +### Correlating and causing messages + +Every message has a `correlation_id` (the id of the message that *started* the +conversation) and a `causation_id` (the id of the message that *directly caused* this +one). When one service needs to call another, build the child message with +`new_caused_by/3` so the chain is preserved: + +```elixir +:get_owner_details +|> SysMsg.new_caused_by(msg, raw_request: %{"owner_id" => owner_id}) +|> X3m.System.Dispatcher.dispatch() +``` + +## The router + +A router registers services and decides who is allowed to call them. + +```elixir +defmodule MyApp.Router do + use X3m.System.Router + + service :open_account, MyApp.Accounts + service :get_account, MyApp.Accounts, :read # different function name + servicep :rebuild_projection, MyApp.Accounts # private to this node + + def authorize(%X3m.System.Message{service_name: :open_account, assigns: %{invoked_by: %{admin?: true}}}), + do: :ok + + def authorize(_message), do: :forbidden +end +``` + +- `service/2` and `service/3` register a **public** service — one that is announced to + other nodes in the cluster. +- `servicep/2` and `servicep/3` register a **private** service — usable on the local + node but never advertised to peers. +- `authorize/1` runs before the handler. Return `:ok` to proceed; any other value + becomes the response sent back to the caller. If you don't define a catch-all clause, + the router denies by default. + +Announce the services at runtime (typically from `start/2`): + +```elixir +:ok = MyApp.Router.register_services() +``` + +You can introspect what a router registered with `registered_services/1` +(`:public`, `:private`, or `:all`). + +## Handlers + +A handler is just a function that takes a `X3m.System.Message` and returns either: + +- `{:reply, message}` — send `message` back to the caller, or +- `:noreply` — send nothing. + +```elixir +defmodule MyApp.Accounts do + alias X3m.System.Message + + def read(%Message{} = message) do + account = lookup(message.raw_request["id"]) + {:reply, Message.ok(message, account)} + end +end +``` + +`X3m.System.Message` provides helpers that set the response and mark the message done: +`ok/1`, `ok/2`, `created/2`, `error/2`, and the lower-level `return/2`. + +## Dispatching + +`X3m.System.Dispatcher.dispatch/2` discovers a node offering the service, invokes it, +and waits for the reply: + +```elixir +:open_account +|> SysMsg.new(raw_request: %{"id" => "acc-1"}) +|> X3m.System.Dispatcher.dispatch(timeout: 10_000) +``` + +- The default timeout is 5000 ms; on expiry the response becomes + `{:service_timeout, service_name, message_id, timeout}`. +- If no node offers the service, the response is + `{:service_unavailable, service_name}`. + +To check a command without committing its effects, use `validate/1` — it dispatches +with `dry_run: true`, so aggregate-backed services run the command and report whether +it *would* succeed without persisting events. See +[Aggregates & event sourcing](aggregates-and-event-sourcing.md) for `dry_run` details. + +## Responses + +By the time `dispatch/2` returns, `message.response` holds one of the shapes described +by `X3m.System.Response`. Callers pattern-match on it: + +```elixir +case X3m.System.Dispatcher.dispatch(msg) do + %SysMsg{response: {:ok, account}} -> ... + %SysMsg{response: {:created, id, _version}} -> ... + %SysMsg{response: {:validation_error, changeset}} -> ... + %SysMsg{response: {:error, reason}} -> ... +end +``` + +To run services across more than one node, continue with [Distribution](distribution.md). diff --git a/guides/scheduling.md b/guides/scheduling.md new file mode 100644 index 0000000..abf8a1b --- /dev/null +++ b/guides/scheduling.md @@ -0,0 +1,115 @@ +# Scheduling + +`X3m.System.Scheduler` delivers a `X3m.System.Message` at some point in the future. Think +of it as a **persistable `Process.send_after/3`**: scheduled messages survive a restart +because you persist them, and they are dispatched through the normal +`X3m.System.Dispatcher` when their time comes. + +The scheduler is backend-agnostic and standalone — it doesn't require aggregates or any +particular store. It needs the optional `:tzdata` dependency. + +## Defining a scheduler + +`use X3m.System.Scheduler` and implement the callbacks that persist and load alarms: + +```elixir +defmodule MyApp.Scheduler do + use X3m.System.Scheduler + alias X3m.System.Message, as: SysMsg + + @impl X3m.System.Scheduler + def save_alarm(%SysMsg{} = msg, aggregate_id, repo) do + repo.insert_alarm(msg, aggregate_id, msg.assigns.dispatch_at) + :ok + end + + @impl X3m.System.Scheduler + def load_alarms(from, until, repo) do + {:ok, repo.alarms_between(from, until)} + end + + @impl X3m.System.Scheduler + def service_responded(%SysMsg{} = msg, repo) do + repo.delete_alarm(msg.id) + :ok + end +end +``` + +Start it with the state that every callback receives as its last argument — typically +your repo or any context the callbacks need: + +```elixir +{:ok, _pid} = MyApp.Scheduler.start_link(MyApp.AlarmsRepo) +``` + +(Add it to your supervision tree the same way.) + +## Scheduling a message + +Use `dispatch/3` with either a delay `in:` milliseconds or an absolute `at:` +`DateTime`: + +```elixir +msg = X3m.System.Message.new(:send_reminder, raw_request: %{"id" => "acc-1"}) + +# in one hour +MyApp.Scheduler.dispatch(msg, "acc-1", in: 60 * 60 * 1_000) + +# at a specific time +MyApp.Scheduler.dispatch(msg, "acc-1", at: ~U[2026-01-01 09:00:00Z]) +``` + +The scheduler assigns the resolved `:dispatch_at` onto the message, calls your +`save_alarm/3` so it is persisted, and arms delivery. When the time arrives the message +is sent through `X3m.System.Dispatcher.dispatch/2` to its `service_name`. + +## Persistence and the in-memory window + +Not every future alarm is kept in memory. The scheduler loads alarms in bulk every +`in_memory_interval/0` milliseconds, covering the next `2 * in_memory_interval/0` +window. On startup it calls `load_alarms/3` with `from: nil`; after that, `from` is the +previous `until`. This is why you persist alarms in `save_alarm/3` and reload them in +`load_alarms/3` — a far-future alarm may be persisted now and only loaded into memory +shortly before it is due, and it is reloaded after a restart. + +If a message with the same `X3m.System.Message.id` is already scheduled in memory, it is +ignored (so duplicate scheduling is safe within a window). + +## Retries + +After a scheduled message is dispatched, `service_responded/2` is called with the +resolved message. Return: + +- `:ok` — delivery succeeded; drop the alarm (delete it from your store). +- `{:retry, in_ms, message}` — redeliver `message` in `in_ms` milliseconds. + +You can track attempts via the message's `assigns`: + +```elixir +@impl X3m.System.Scheduler +def service_responded(%SysMsg{response: {:ok, _}} = msg, repo) do + repo.delete_alarm(msg.id) + :ok +end + +def service_responded(%SysMsg{assigns: %{dispatch_attempts: n}} = msg, _repo) when n < 5 do + {:retry, 30_000, msg} +end + +def service_responded(%SysMsg{} = msg, repo) do + repo.mark_failed(msg.id) + :ok +end +``` + +`dispatch_attempts` is incremented for you on each delivery. + +## Optional callbacks + +Two callbacks have defaults you can override: + +- `in_memory_interval/0` — how often (ms) alarms are loaded into memory. Default is 6 + hours. +- `dispatch_timeout/1` — how long (ms) to wait for the dispatched service to respond. + Default is 5000. diff --git a/lib/aggregate.ex b/lib/aggregate.ex index a3f92ac..e8b7762 100644 --- a/lib/aggregate.ex +++ b/lib/aggregate.ex @@ -1,15 +1,84 @@ defmodule X3m.System.Aggregate do + @moduledoc """ + Behaviour and macros for defining an aggregate: the unit that decides how a command + is handled and how events change its state. + + An aggregate is a plain module that `use`s this one. It declares its starting state + with `c:initial_state/0`, one `handle_msg/2` (or `handle_msg/3`) clause per command, + and an `apply_event/2` clause per event: + + defmodule MyApp.Accounts.Aggregate do + use X3m.System.Aggregate + alias X3m.System.Message, as: SysMsg + + defmodule State, do: defstruct [:id, balance: 0] + + @impl X3m.System.Aggregate + def initial_state, do: %State{} + + # validate with Command.new/2, then run Handler.process/2 on success + handle_msg :open_account, &Command.Open.new/2, &Handler.Open.process/2 + + # or a single function that returns {:block | :noblock, message, state} + handle_msg :deposit, fn %SysMsg{} = msg, %State{} = state -> + event = %Events.Deposited{id: state.id, amount: msg.request.amount} + + msg = + msg + |> SysMsg.add_event(event) + |> SysMsg.ok() + + {:block, msg, state} + end + + def apply_event(%Events.Opened{} = e, %State{} = state), + do: %State{state | id: e.id} + + def apply_event(%Events.Deposited{} = e, %State{} = state), + do: %State{state | balance: state.balance + e.amount} + end + + A command handler returns one of: + + * `{:block, message, state}` - there are events to persist; the message handler + saves them and replies once the commit succeeds. + * `{:noblock, message, state}` - nothing to persist; the response is returned as-is. + + The macros wrap your functions with idempotency (a message whose id was already + processed returns `:ok` without re-running) and telemetry. State is rebuilt from the + event stream by replaying `apply_event/2`, so aggregates are event-sourced by default; + override `processed_message_id/1`, `commit/2` and `rollback/2` for finer control. + + See the "Aggregates & Event Sourcing" guide for the full flow. + """ + defmodule State do + @moduledoc !""" + Internal. Wraps an aggregate's client state with its version and the set of + already-processed message ids. + """ @type t :: %__MODULE__{version: integer, client_state: any} defstruct version: -1, client_state: nil, processed_messages: MapSet.new() end + @doc """ + Returns the aggregate's starting client state (before any events are applied). + """ @callback initial_state :: map() - @spec initial_state(atom) :: State.t() + # Wraps `aggregate_mod`'s `initial_state/0` in the internal `State` struct. Used by + # the aggregate process when it spawns; not part of the public API. + @doc false + @spec initial_state(aggregate_mod :: module()) :: State.t() def initial_state(aggregate_mod), do: %State{client_state: apply(aggregate_mod, :initial_state, [])} + @doc """ + Defines a command handler `msg_name/2` from a single `fun`. + + `fun` receives the `X3m.System.Message` and the current client state and must return + `{:block | :noblock, message, state}`. + """ defmacro handle_msg(msg_name, fun) do quote do @spec unquote(msg_name)(X3m.System.Message.t(), X3m.System.Aggregate.State.t()) :: @@ -37,6 +106,14 @@ defmodule X3m.System.Aggregate do end end + @doc """ + Defines a command handler `msg_name/2` that first validates, then processes. + + `validate_fun` receives the message and client state. If it returns a halted message + (e.g. via `X3m.System.Message.put_request/2` on an invalid changeset) processing + stops and that message is returned. Otherwise `on_success` runs and must return + `{:block | :noblock, message, state}`. + """ defmacro handle_msg(msg_name, validate_fun, on_success) do quote do @spec unquote(msg_name)(X3m.System.Message.t(), X3m.System.Aggregate.State.t()) :: diff --git a/lib/aggregate_group.ex b/lib/aggregate_group.ex index 76fd3a2..896516c 100644 --- a/lib/aggregate_group.ex +++ b/lib/aggregate_group.ex @@ -1,4 +1,8 @@ defmodule X3m.System.AggregateGroup do + @moduledoc !""" + Internal. Per-aggregate-type supervisor that groups one aggregate module's + pid manager and pid facade under a single supervision subtree. + """ use Supervisor alias X3m.System.{AggregatePidFacade, AggregatePidManager} diff --git a/lib/aggregate_pid_facade.ex b/lib/aggregate_pid_facade.ex index 208a0cb..79094bf 100644 --- a/lib/aggregate_pid_facade.ex +++ b/lib/aggregate_pid_facade.ex @@ -1,4 +1,8 @@ defmodule X3m.System.AggregatePidFacade do + @moduledoc !""" + Internal. Default `pid_facade_mod` for message handlers: spawns, locates and + tears down per-id aggregate processes via the aggregate registry and supervisor. + """ use GenServer require Logger alias X3m.System.AggregateSup diff --git a/lib/aggregate_pid_manager.ex b/lib/aggregate_pid_manager.ex index 33f96f7..ee6584f 100644 --- a/lib/aggregate_pid_manager.ex +++ b/lib/aggregate_pid_manager.ex @@ -1,4 +1,8 @@ defmodule X3m.System.AggregatePidManager do + @moduledoc !""" + Internal. Supervises one aggregate module's registry and dynamic supervisor + together (`:one_for_all`) so they restart as a unit. + """ use Supervisor alias X3m.System.AggregateRegistry, as: Registry alias X3m.System.AggregateSup diff --git a/lib/aggregate_registry.ex b/lib/aggregate_registry.ex index cff4176..4ad4271 100644 --- a/lib/aggregate_registry.ex +++ b/lib/aggregate_registry.ex @@ -1,7 +1,8 @@ defmodule X3m.System.AggregateRegistry do - @moduledoc """ - Keeps track of registered aggregate pids. - """ + @moduledoc !""" + Internal. Keeps track of registered aggregate pids (per aggregate module) in + ETS, demonitoring and dropping them when the processes go down. + """ require Logger use GenServer diff --git a/lib/aggregate_repo.ex b/lib/aggregate_repo.ex index 7e52c7c..16784e9 100644 --- a/lib/aggregate_repo.ex +++ b/lib/aggregate_repo.ex @@ -1,15 +1,68 @@ defmodule X3m.System.Aggregate.Repo do + @moduledoc """ + Behaviour for the event store that backs aggregates. + + An `X3m.System.MessageHandler` reads an aggregate's history and persists its new + events through a module implementing this behaviour, passed as the `:aggregate_repo` + option. The library does not ship a concrete store — you implement these four + callbacks against whatever you use (EventStoreDB/Extreme, Postgres, an in-memory + store for tests, ...): + + defmodule MyApp.AggregateRepo do + use X3m.System.Aggregate.Repo + + @impl true + def has?(stream_name), do: ... + + @impl true + def stream_events(stream_name, start_at, per_page), do: ... + + @impl true + def delete_stream(stream_name, hard_delete?, expected_version), do: ... + + @impl true + def save_events(stream_name, message, events_metadata), do: ... + end + + Stream names are built by the message handler from its `:stream` option and the + aggregate id (`"\#{stream}-\#{id}"`). + """ + + @doc """ + Returns whether a stream named `stream_name` exists (i.e. the aggregate has any + persisted events). + """ @callback has?(stream_name :: String.t()) :: boolean + + @doc """ + Returns an enumerable of `{event, event_number, metadata}` tuples for `stream_name`, + starting at `start_at` and read in pages of `per_page`. Replayed to rebuild state. + """ @callback stream_events( stream_name :: String.t(), start_at :: non_neg_integer(), per_page :: pos_integer() ) :: Enumerable.t() + + @doc """ + Deletes `stream_name`. `hard_delete?` chooses a hard vs soft delete; `expected_version` + enables optimistic-concurrency checks (`-2` to skip). + """ @callback delete_stream( stream_name :: String.t(), hard_delete? :: boolean, expected_version :: integer() ) :: :ok + + @doc """ + Appends `message.events` to `stream_name`, storing `events_metadata` alongside each + event. + + Returns `{:ok, last_event_number}` with the stream's new version on success. Return + `{:error, :wrong_expected_version, expected_last_event_number}` on a concurrency + conflict, or `{:error, reason}` for any other failure (the aggregate process is then + terminated and the error returned to the caller). + """ @callback save_events( stream_name :: String.t(), message :: X3m.System.Message.t(), @@ -17,7 +70,7 @@ defmodule X3m.System.Aggregate.Repo do ) :: {:ok, last_event_number :: integer} | {:error, :wrong_expected_version, expected_last_event_number :: integer} - | {:error, any} + | {:error, reason :: any} defmacro __using__(_opts) do quote do diff --git a/lib/aggregate_sup.ex b/lib/aggregate_sup.ex index 0c71875..55476e1 100644 --- a/lib/aggregate_sup.ex +++ b/lib/aggregate_sup.ex @@ -1,4 +1,8 @@ defmodule X3m.System.AggregateSup do + @moduledoc !""" + Internal. Dynamic supervisor that starts and terminates individual + `X3m.System.GenAggregate` processes for one aggregate module. + """ use DynamicSupervisor def name(aggregate_mod), diff --git a/lib/application.ex b/lib/application.ex index 90a95b6..e853e16 100644 --- a/lib/application.ex +++ b/lib/application.ex @@ -1,5 +1,8 @@ defmodule X3m.System.Application do - @moduledoc false + @moduledoc !""" + Internal. OTP application that starts the task supervisor, node monitor and + service registry, and wires up node telemetry handlers. + """ use Application diff --git a/lib/dispatcher.ex b/lib/dispatcher.ex index 904365f..00322a5 100644 --- a/lib/dispatcher.ex +++ b/lib/dispatcher.ex @@ -1,6 +1,35 @@ defmodule X3m.System.Dispatcher do + @moduledoc """ + Sends a `X3m.System.Message` to whichever node offers its service and waits for the + response. + + This is the client-facing entry point of the messaging layer. You build a message + with `X3m.System.Message.new/2`, optionally `assign` values onto it, and then call + `dispatch/2`: + + :open_account + |> X3m.System.Message.new(raw_request: %{"id" => id}) + |> X3m.System.Dispatcher.dispatch() + + Service discovery is transparent: the dispatcher asks the (internal) service + registry which nodes provide `message.service_name`. A **local** provider is invoked + directly; **remote** providers are invoked over `:rpc`. The reply is delivered back + to the calling process, so `dispatch/2` returns the resolved `X3m.System.Message` + with its `response` set. See the "Distribution" guide for how nodes are chosen and + how a service can ask the dispatcher to `:try_another_node`. + + Using the dispatcher does **not** require aggregates or event sourcing — any module + registered through `X3m.System.Router` can be a dispatch target. + """ alias X3m.System.{Message, Response, Instrumenter, ServiceRegistry} + @doc """ + Returns whether the (discovered) service authorizes `message`. + + Discovery is performed first; if no node offers the service `:service_unavailable` + is returned. Otherwise authorization is delegated to the providing node's router + (`X3m.System.Router` `authorize/1`). + """ @spec authorized?(Message.t()) :: boolean() | {:service_unavailable, atom} def authorized?(%Message{} = message) do mono_start = System.monotonic_time() @@ -45,6 +74,23 @@ defmodule X3m.System.Dispatcher do def validate(%Message{} = message), do: dispatch(message) + @doc """ + Discovers a node offering `message.service_name`, invokes the service there and + returns the resolved `message` with its `response` set. + + A halted message (`halted?: true`) is returned untouched. + + Options: + + * `:timeout` - milliseconds to wait for the service reply (default `5_000`). On + expiry the response is set to `Response.service_timeout/3`. + + If no node offers the service the response is set to `Response.service_unavailable/1`. + When several nodes offer it, one is picked at random; a node may reply with + `{:error, {:try_another_node, reason}}` to make the dispatcher try the next one. + """ + @spec dispatch(Message.t()) :: Message.t() + @spec dispatch(Message.t(), opts :: Keyword.t()) :: Message.t() def dispatch(%Message{halted?: true} = message), do: message def dispatch(%Message{} = message, opts \\ []) do @@ -99,6 +145,13 @@ defmodule X3m.System.Dispatcher do message end + @doc """ + Looks up which nodes offer `message.service_name`. + + Returns `:not_found` when no node provides it, or a list of + `{:local | node, router_module}` pairs otherwise. Used internally by `dispatch/2` + and `authorized?/1`; exposed for introspection. + """ @spec discover_service(Message.t()) :: :not_found | [{:local | atom(), router_mod :: module()}] diff --git a/lib/gen_aggregate.ex b/lib/gen_aggregate.ex index cba3691..6fae6ef 100644 --- a/lib/gen_aggregate.ex +++ b/lib/gen_aggregate.ex @@ -1,10 +1,16 @@ defmodule X3m.System.GenAggregate do + @moduledoc !""" + Internal. GenServer hosting a single aggregate instance: applies event + streams, runs commands against the aggregate module and manages the + block/commit transaction protocol. + """ use GenServer, restart: :transient alias X3m.System.Message @behaviour X3m.System.GenAggregateMod defmodule State do + @moduledoc !"Internal. State of a `X3m.System.GenAggregate` process." @enforce_keys ~w(aggregate_mod aggregate_state commit_timeout)a defstruct @enforce_keys end diff --git a/lib/gen_aggregate_mod.ex b/lib/gen_aggregate_mod.ex index 5dbcd25..a9175f8 100644 --- a/lib/gen_aggregate_mod.ex +++ b/lib/gen_aggregate_mod.ex @@ -1,4 +1,8 @@ defmodule X3m.System.GenAggregateMod do + @moduledoc !""" + Internal. Behaviour implemented by `X3m.System.GenAggregate`, letting message + handlers talk to the aggregate process through a swappable contract. + """ @callback apply_event_stream(pid, function) :: :ok @callback handle_msg(pid, atom, X3m.System.Message.t(), Keyword.t()) :: {:ok, X3m.System.Message.t(), any} | any diff --git a/lib/instrumenter.ex b/lib/instrumenter.ex index f5777c2..653a090 100644 --- a/lib/instrumenter.ex +++ b/lib/instrumenter.ex @@ -1,4 +1,8 @@ defmodule X3m.System.Instrumenter do + @moduledoc !""" + Internal. Thin wrapper over `:telemetry.execute/3` for the `[:x3m, :system, _]` + event prefix, plus a monotonic-duration helper. + """ @spec execute(atom, map, map) :: :ok def execute(name, measurements \\ %{}, data \\ %{}), do: :telemetry.execute([:x3m, :system, name], measurements, data) diff --git a/lib/local_aggregates.ex b/lib/local_aggregates.ex index aa180a9..c7b1c32 100644 --- a/lib/local_aggregates.ex +++ b/lib/local_aggregates.ex @@ -1,4 +1,17 @@ defmodule X3m.System.LocalAggregates do + @moduledoc """ + Declares which aggregate modules run on the local node. + + `use` it with the list of aggregate modules to host; it defines a supervisor that + starts an aggregate group per module: + + defmodule MyApp.LocalAggregates do + use X3m.System.LocalAggregates, [MyApp.Accounts.Aggregate, MyApp.Orders.Aggregate] + end + + Wire it into your supervision tree through `X3m.System.LocalAggregatesSupervision`. + See the "Aggregates & Event Sourcing" guide for the full setup. + """ defmacro __using__(opts) do quote do @moduledoc false diff --git a/lib/local_aggregates_supervision.ex b/lib/local_aggregates_supervision.ex index 7ac2b43..3dcc958 100644 --- a/lib/local_aggregates_supervision.ex +++ b/lib/local_aggregates_supervision.ex @@ -1,4 +1,14 @@ defmodule X3m.System.LocalAggregatesSupervision do + @moduledoc """ + Supervises the aggregate processes that run on the local node. + + Add it to your application's supervision tree, pointing it at your + `X3m.System.LocalAggregates` configuration module and an app prefix: + + {X3m.System.LocalAggregatesSupervision, [MyApp.LocalAggregates, MyApp]} + + See the "Aggregates & Event Sourcing" guide for the full setup. + """ use Supervisor def start_link([configuration_module, prefix]), diff --git a/lib/message.ex b/lib/message.ex index ab86d71..b3ee3b3 100644 --- a/lib/message.ex +++ b/lib/message.ex @@ -114,7 +114,12 @@ defmodule X3m.System.Message do ) end - @spec to_service(t(), atom) :: t() + @doc """ + Returns `sys_msg` re-targeted at a different `service_name`, leaving its ids, + payload and assigns intact. Useful for re-dispatching the same request to another + service. + """ + @spec to_service(t(), service_name :: atom) :: t() def to_service(%__MODULE__{} = sys_msg, service_name), do: %__MODULE__{sys_msg | service_name: service_name} @@ -126,9 +131,10 @@ defmodule X3m.System.Message do ## Examples + iex> sys_msg = X3m.System.Message.new(:create_user) iex> sys_msg.assigns[:user_id] nil - iex> sys_msg = assign(sys_msg, :user_id, 123) + iex> sys_msg = X3m.System.Message.assign(sys_msg, :user_id, 123) iex> sys_msg.assigns[:user_id] 123 """ @@ -174,6 +180,15 @@ defmodule X3m.System.Message do return(message, response) end + @doc """ + Stores a validated `request` (e.g. an `Ecto.Changeset` or a command struct) on the + `message`. + + If `request` carries `valid?: false`, the message is halted with a + `Response.validation_error/1` so dispatch returns the error immediately. Otherwise + the request is stored and `message.valid?` is set to `true`. + """ + @spec put_request(request :: map(), t()) :: t() def put_request(%{valid?: false} = request, %__MODULE__{} = message) do %{message | valid?: false, request: request} |> return(Response.validation_error(request)) @@ -185,6 +200,7 @@ defmodule X3m.System.Message do @doc """ Puts `value` under `key` in `message.raw_request` map. """ + @spec put_in_raw_request(t(), key :: term(), value :: term()) :: t() def put_in_raw_request(%__MODULE__{} = message, key, value) do raw_request = (message.raw_request || %{}) @@ -207,6 +223,20 @@ defmodule X3m.System.Message do def add_event(%__MODULE__{events: events} = message, event), do: %{message | events: [event | events]} + @doc """ + Extracts the aggregate id from `message.raw_request` under `id_field` and copies it + into `message.aggregate_meta.id`. + + Options: + + * `:generate_if_missing` - when `true` and the id is absent, a fresh UUID is + generated and written into both `raw_request` and `aggregate_meta`. + + When the id is missing and `:generate_if_missing` is `false` (the default), the + message is halted with a `Response.missing_id/1` response. This is what the + `X3m.System.MessageHandler` `on_new_aggregate` / `on_aggregate` macros call for you. + """ + @spec prepare_aggregate_id(t(), id_field :: term(), opts :: Keyword.t()) :: t() def prepare_aggregate_id(%__MODULE__{} = message, id_field, opts \\ []) do id = message @@ -233,6 +263,11 @@ defmodule X3m.System.Message do end end + @doc """ + Generates a new, URL-safe, unique message id. + + This is the same id format used by `new/2` when no `:id` is given. + """ # taken from https://github.com/elixir-plug/plug/blob/master/lib/plug/request_id.ex @spec gen_msg_id :: String.t() def gen_msg_id() do diff --git a/lib/message_handler.ex b/lib/message_handler.ex index 55d189b..d3d68b7 100644 --- a/lib/message_handler.ex +++ b/lib/message_handler.ex @@ -1,4 +1,57 @@ defmodule X3m.System.MessageHandler do + @moduledoc """ + Connects a router service to an aggregate, taking care of loading the aggregate, + running the command, persisting the produced events and replying to the caller. + + A message handler is the glue between `X3m.System.Router` (which routes a service + call) and `X3m.System.Aggregate` (which decides what happens). You `use` it with the + collaborators it needs, then declare one function per service with the `on_*_aggregate` + macros: + + defmodule MyApp.Accounts.MessageHandler do + use X3m.System.MessageHandler, + aggregate_mod: MyApp.Accounts.Aggregate, + aggregate_repo: MyApp.Accounts.AggregateRepo, + stream: "accounts", + pid_facade_mod: X3m.System.AggregatePidFacade, + event_metadata: %{app_version: "1.0.0"} + + on_new_aggregate :open_account, id: :id + on_aggregate :deposit, id: :account_id + on_maybe_new_aggregate :ensure_account, id: :id + end + + ## `use` options + + * `:aggregate_mod` (required) - the `X3m.System.Aggregate` module that handles the + command and applies events. + * `:aggregate_repo` (required) - a module implementing `X3m.System.Aggregate.Repo`, + used to read and persist the event stream. + * `:pid_facade_mod` (required) - the process facade that spawns/locates the + aggregate process. Use the default shown above unless you provide your own + (e.g. to use a distributed registry such as Horde). + * `:stream` - prefix for the per-aggregate stream name (`"\#{stream}-\#{id}"`). + * `:event_metadata` - a map merged into the metadata stored with every event. + * `:unload_aggregate_on` - rules for tearing the aggregate process down after + certain events or states, to free memory. + + ## Lifecycle + + Each declared function: + + 1. extracts the aggregate id from `message.raw_request` (see + `X3m.System.Message.prepare_aggregate_id/3`), + 2. loads or spawns the aggregate process and runs the command on it, + 3. on a `{:block, ...}` result, persists the events via the repo and commits, + 4. enriches the response with the new aggregate version + (`{:ok, version}` / `{:created, id, version}`) and replies to `message.reply_to`. + + Snapshotting and other persistence strategies are supported by overriding the + generated `save_state/3` and `when_pid_is_not_registered/3` callbacks. + + See the "Aggregates & Event Sourcing" guide for a full walk-through. + """ + defmacro on_new_aggregate(cmd, opts \\ []) do id_field = Keyword.get(opts, :id, "id") commit_timeout = Keyword.get(opts, :commit_timeout, 5_000) diff --git a/lib/node_monitor.ex b/lib/node_monitor.ex index 3d88f08..4ec19ec 100644 --- a/lib/node_monitor.ex +++ b/lib/node_monitor.ex @@ -1,4 +1,8 @@ defmodule X3m.System.NodeMonitor do + @moduledoc !""" + Internal. Watches cluster membership and emits `:node_joined` / `:node_left` + telemetry events used to keep the service registry in sync. + """ def child_spec(_opts) do %{ id: __MODULE__, diff --git a/lib/response.ex b/lib/response.ex index 8d5a4b6..e8b42f6 100644 --- a/lib/response.ex +++ b/lib/response.ex @@ -1,45 +1,158 @@ defmodule X3m.System.Response do + @moduledoc """ + The set of responses a service call can return. + + Every `X3m.System.Message` carries a `response` field. By the time a dispatched + message comes back to the caller, that field holds one of the values described by + `t:t/0`. Callers (controllers, other services, tests) typically `case`/pattern-match + on it: + + case Dispatcher.dispatch(message) do + %X3m.System.Message{response: {:created, id, _version}} -> ... + %X3m.System.Message{response: {:validation_error, changeset}} -> ... + %X3m.System.Message{response: {:error, reason}} -> ... + end + + The functions in this module are thin constructors for those shapes. You rarely call + them directly — `X3m.System.Message.ok/1`, `created/2`, `error/2` and friends build + them for you — but they document the full vocabulary of responses the system speaks. + + ## Response shapes + + * `:ok` / `{:ok, payload}` / `{:ok, payload, version}` — success. The 3-tuple is + returned by aggregate-backed services and carries the aggregate's new version. + * `{:created, id}` / `{:created, id, version}` — a new aggregate was created. + * `{:validation_error, request}` — the request failed validation (e.g. an invalid + `Ecto.Changeset`). + * `{:missing_id, id_field}` — the request did not contain the expected aggregate id. + * `{:service_unavailable, service_name}` — no node in the cluster offers the service. + * `{:service_timeout, service_name, message_id, timeout}` — the service did not reply + within the dispatch timeout. + * `{:error, reason}` — any other domain or infrastructure error. + """ + @type t :: :ok - | {:ok, any} - | {:ok, any, integer} - | {:created, any} - | {:created, any, integer} - | {:service_unavailable, atom} - | {:service_timeout, atom, String.t(), non_neg_integer} - | {:validation_error, map} - | {:missing_id, atom | String.t()} - | {:error, any} + | {:ok, payload :: any} + | {:ok, payload :: any, version :: integer} + | {:created, id :: any} + | {:created, id :: any, version :: integer} + | {:service_unavailable, service_name :: atom} + | {:service_timeout, service_name :: atom, message_id :: String.t(), + timeout :: non_neg_integer} + | {:validation_error, request :: map} + | {:missing_id, id_field :: atom | String.t()} + | {:error, reason :: any} + @doc """ + Builds the bare success response. + + ## Examples + + iex> X3m.System.Response.ok() + :ok + """ @spec ok() :: :ok def ok, do: :ok - @spec ok(any) :: {:ok, any} + @doc """ + Builds a success response carrying a `payload`. + + ## Examples + + iex> X3m.System.Response.ok(%{balance: 10}) + {:ok, %{balance: 10}} + """ + @spec ok(payload :: any) :: {:ok, payload :: any} def ok(payload), do: {:ok, payload} - @spec created(any) :: {:created, any} + @doc """ + Builds a response signalling that a new aggregate with `id` was created. + + ## Examples + + iex> X3m.System.Response.created("acc-1") + {:created, "acc-1"} + """ + @spec created(id :: any) :: {:created, id :: any} def created(id), do: {:created, id} - @spec service_unavailable(atom) :: {:service_unavailable, atom} + @doc """ + Builds a response signalling that no node offers `msg_name`. + + ## Examples + + iex> X3m.System.Response.service_unavailable(:open_account) + {:service_unavailable, :open_account} + """ + @spec service_unavailable(service_name :: atom) :: {:service_unavailable, service_name :: atom} def service_unavailable(msg_name), do: {:service_unavailable, msg_name} - @spec service_timeout(atom, String.t(), non_neg_integer) :: - {:service_timeout, atom, String.t(), non_neg_integer} + @doc """ + Builds a response signalling that `msg_name` (with message id `req_id`) did not + reply within `timeout` milliseconds. + + ## Examples + + iex> X3m.System.Response.service_timeout(:open_account, "req-1", 5_000) + {:service_timeout, :open_account, "req-1", 5_000} + """ + @spec service_timeout( + service_name :: atom, + message_id :: String.t(), + timeout :: non_neg_integer + ) :: + {:service_timeout, service_name :: atom, message_id :: String.t(), + timeout :: non_neg_integer} def service_timeout(msg_name, req_id, timeout), do: {:service_timeout, msg_name, req_id, timeout} - @spec unauthorized(String.t()) :: {:unauthorized, String.t()} + @doc """ + Builds an unauthorized response with an explanatory `msg`. + + ## Examples + + iex> X3m.System.Response.unauthorized("admins only") + {:unauthorized, "admins only"} + """ + @spec unauthorized(msg :: String.t()) :: {:unauthorized, msg :: String.t()} def unauthorized(msg), do: {:unauthorized, msg} - @spec validation_error(map) :: {:validation_error, map} + @doc """ + Builds a validation-error response wrapping the (invalid) `request`, + typically an `Ecto.Changeset`. + + ## Examples + + iex> X3m.System.Response.validation_error(%{valid?: false}) + {:validation_error, %{valid?: false}} + """ + @spec validation_error(request :: map) :: {:validation_error, request :: map} def validation_error(request), do: {:validation_error, request} - @spec missing_id(atom | String.t()) :: {:missing_id, atom | String.t()} + @doc """ + Builds a response signalling that the request is missing the aggregate id + expected under `id_field`. + + ## Examples + + iex> X3m.System.Response.missing_id("id") + {:missing_id, "id"} + """ + @spec missing_id(id_field :: atom | String.t()) :: {:missing_id, id_field :: atom | String.t()} def missing_id(id_field), do: {:missing_id, id_field} - @spec error(any) :: {:error, any} + @doc """ + Builds a generic error response wrapping `reason`. + + ## Examples + + iex> X3m.System.Response.error(:not_found) + {:error, :not_found} + """ + @spec error(reason :: any) :: {:error, reason :: any} def error(any), do: {:error, any} end diff --git a/lib/router.ex b/lib/router.ex index 0659e63..ee66f59 100644 --- a/lib/router.ex +++ b/lib/router.ex @@ -46,7 +46,9 @@ defmodule X3m.System.Router do ## Examples - ### Defining router ensuring remote callers don't receive logger stdout + ### Defining a router ensuring remote callers don't receive logger stdout + + This is the default behaviour, so defmodule MyRouter do use X3m.System.Router @@ -54,15 +56,19 @@ defmodule X3m.System.Router do ... end - is identical to + is identical to defmodule MyRouter do - use X3m.System.Router + use X3m.System.Router, ensure_local_logging?: true ... end - ### Defining router with default logger behaviour + ### Defining a router that keeps REPL logging behaviour + + Pass `ensure_local_logging?: false` so log messages emitted by the called node are + shown in the *caller* node's stdout (useful when driving services from an `iex` + session): defmodule MyRouter do use X3m.System.Router, ensure_local_logging?: false diff --git a/lib/scheduler.ex b/lib/scheduler.ex index 338f1e9..a1fdb43 100644 --- a/lib/scheduler.ex +++ b/lib/scheduler.ex @@ -72,6 +72,7 @@ defmodule X3m.System.Scheduler do @optional_callbacks in_memory_interval: 0, dispatch_timeout: 1 defmodule State do + @moduledoc !"Internal. State of a `X3m.System.Scheduler` GenServer." @type t() :: %__MODULE__{ client_state: any(), loaded_until: nil | DateTime.t(), diff --git a/lib/service_registry.ex b/lib/service_registry.ex index 9fc1a60..8c97d26 100644 --- a/lib/service_registry.ex +++ b/lib/service_registry.ex @@ -1,4 +1,9 @@ defmodule X3m.System.ServiceRegistry do + @moduledoc !""" + Internal. Cluster-wide registry of which node offers which service; exchanges + local/remote service maps between nodes and answers discovery queries from + `X3m.System.Dispatcher`. + """ use GenServer require Logger alias X3m.System.ServiceRegistry.Implementation, as: Impl diff --git a/lib/service_registry/implementation.ex b/lib/service_registry/implementation.ex index a56de75..9ccd0fa 100644 --- a/lib/service_registry/implementation.ex +++ b/lib/service_registry/implementation.ex @@ -1,5 +1,8 @@ defmodule X3m.System.ServiceRegistry.Implementation do - @moduledoc false + @moduledoc !""" + Internal. Pure state transitions for `X3m.System.ServiceRegistry`: + registering and removing remote services per node. + """ alias X3m.System.ServiceRegistry.State @spec register_remote_services({node, [{atom, atom}]}, State.t()) :: {:ok, State.t()} diff --git a/lib/service_registry/state.ex b/lib/service_registry/state.ex index 01f583b..8b6ec69 100644 --- a/lib/service_registry/state.ex +++ b/lib/service_registry/state.ex @@ -1,5 +1,10 @@ defmodule X3m.System.ServiceRegistry.State do + @moduledoc !""" + Internal. State struct for `X3m.System.ServiceRegistry`, holding the local, + public and remote service maps. + """ defmodule Services do + @moduledoc !"Internal. Local/public/remote service maps held by the service registry." @type t() :: %__MODULE__{local: local_services(), remote: remote_services()} @type local_services() :: %{service_name() => node()} @type remote_services() :: %{service_name() => %{node() => module :: atom()}} diff --git a/lib/service_telemetry_handler.ex b/lib/service_telemetry_handler.ex index 209ef42..e3541b1 100644 --- a/lib/service_telemetry_handler.ex +++ b/lib/service_telemetry_handler.ex @@ -1,4 +1,8 @@ defmodule X3m.System.ServiceTelemetryHandler do + @moduledoc !""" + Internal. Attaches to node up/down telemetry events and tells the service + registry to introduce or unregister a node's services. + """ require Logger def setup do diff --git a/mix.exs b/mix.exs index c682d42..86c8342 100644 --- a/mix.exs +++ b/mix.exs @@ -1,12 +1,16 @@ defmodule X3m.System.MixProject do use Mix.Project + @version "0.9.1" + @source_url "https://github.com/x3m-ex/system" + def project do [ app: :x3m_system, - version: "0.9.1", + version: @version, elixir: "~> 1.17", - source_url: "https://github.com/x3m-ex/system", + source_url: @source_url, + homepage_url: @source_url, description: """ Building blocks for distributed and/or CQRS/ES systems """, @@ -14,6 +18,7 @@ defmodule X3m.System.MixProject do start_permanent: true, test_coverage: [tool: ExCoveralls], name: "X3m System", + docs: _docs(), aliases: _aliases(), deps: _deps(), dialyzer: [plt_add_apps: [:ex_unit, :local_cluster]], @@ -21,6 +26,45 @@ defmodule X3m.System.MixProject do ] end + defp _docs do + [ + main: "readme", + source_ref: "v#{@version}", + extras: [ + "README.md", + "guides/getting-started.md", + "guides/messaging.md", + "guides/aggregates-and-event-sourcing.md", + "guides/distribution.md", + "guides/scheduling.md", + "CHANGELOG.md" + ], + groups_for_extras: [ + Guides: ~r"guides/" + ], + groups_for_modules: [ + Messaging: [ + X3m.System.Message, + X3m.System.Dispatcher, + X3m.System.Router, + X3m.System.Response + ], + "Aggregates & Event Sourcing": [ + X3m.System.MessageHandler, + X3m.System.Aggregate, + X3m.System.Aggregate.Repo + ], + Scheduling: [ + X3m.System.Scheduler + ], + Setup: [ + X3m.System.LocalAggregates, + X3m.System.LocalAggregatesSupervision + ] + ] + ] + end + def application do [ mod: {X3m.System.Application, []}, @@ -35,6 +79,7 @@ defmodule X3m.System.MixProject do "coveralls.detail": :test, "coveralls.post": :test, "coveralls.html": :test, + docs: :dev, dialyzer: :test, bless: :test ] @@ -84,10 +129,18 @@ defmodule X3m.System.MixProject do defp _package do [ - files: [".formatter.exs", "lib", "mix.exs", "README*", "LICENSE*"], + files: [ + ".formatter.exs", + "lib", + "guides", + "mix.exs", + "README*", + "CHANGELOG*", + "LICENSE*" + ], maintainers: ["Milan Burmaja"], licenses: ["MIT"], - links: %{"GitHub" => "https://github.com/x3m-ex/system"} + links: %{"GitHub" => @source_url} ] end end diff --git a/test/message_test.exs b/test/message_test.exs new file mode 100644 index 0000000..4e04dc7 --- /dev/null +++ b/test/message_test.exs @@ -0,0 +1,4 @@ +defmodule X3m.System.MessageTest do + use ExUnit.Case, async: true + doctest X3m.System.Message +end diff --git a/test/response_test.exs b/test/response_test.exs new file mode 100644 index 0000000..1b9d43d --- /dev/null +++ b/test/response_test.exs @@ -0,0 +1,4 @@ +defmodule X3m.System.ResponseTest do + use ExUnit.Case, async: true + doctest X3m.System.Response +end From 6351408fad4d160832a9162ffe9d2b9461dfc25c Mon Sep 17 00:00:00 2001 From: Burmaja Milan Date: Tue, 9 Jun 2026 10:20:41 +0100 Subject: [PATCH 4/9] Remove tzdata as a dependency --- README.md | 3 +-- guides/getting-started.md | 3 +-- guides/scheduling.md | 2 +- lib/scheduler.ex | 18 +++++++++--------- mix.exs | 2 -- mix.lock | 19 +++++-------------- 6 files changed, 17 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index fe209cc..46ea038 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,9 @@ def deps do end ``` -Two dependencies are optional and only needed for some building blocks: +One dependency is optional: - `:elixir_uuid` — needed when working with aggregates (id generation). -- `:tzdata` — needed for `X3m.System.Scheduler`. ## A minimal example diff --git a/guides/getting-started.md b/guides/getting-started.md index e6995bf..fefbc99 100644 --- a/guides/getting-started.md +++ b/guides/getting-started.md @@ -16,10 +16,9 @@ def deps do end ``` -Two dependencies are optional and pulled in only for specific building blocks: +One dependency is optional: - `:elixir_uuid` — id generation when working with [aggregates](aggregates-and-event-sourcing.md). -- `:tzdata` — required by the [scheduler](scheduling.md). `X3m.System` starts its own OTP application (a task supervisor, a node monitor and the service registry), so once it is a dependency there is nothing to add to your diff --git a/guides/scheduling.md b/guides/scheduling.md index abf8a1b..f51d60d 100644 --- a/guides/scheduling.md +++ b/guides/scheduling.md @@ -6,7 +6,7 @@ because you persist them, and they are dispatched through the normal `X3m.System.Dispatcher` when their time comes. The scheduler is backend-agnostic and standalone — it doesn't require aggregates or any -particular store. It needs the optional `:tzdata` dependency. +particular store. ## Defining a scheduler diff --git a/lib/scheduler.ex b/lib/scheduler.ex index a1fdb43..960c23d 100644 --- a/lib/scheduler.ex +++ b/lib/scheduler.ex @@ -130,7 +130,9 @@ defmodule X3m.System.Scheduler do """ @spec dispatch(Message.t(), String.t(), opts :: Keyword.t()) :: :ok def dispatch(%Message{} = msg, aggregate_id, in: dispatch_in_ms) do - dispatch_at = DateTime.add(_now(), dispatch_in_ms, :millisecond) + dispatch_at = + DateTime.utc_now() + |> DateTime.add(dispatch_in_ms, :millisecond) GenServer.call( @name, @@ -139,7 +141,9 @@ defmodule X3m.System.Scheduler do end def dispatch(%Message{} = msg, aggregate_id, at: %DateTime{} = dispatch_at) do - dispatch_in_ms = DateTime.diff(dispatch_at, _now(), :millisecond) + dispatch_in_ms = + dispatch_at + |> DateTime.diff(DateTime.utc_now(), :millisecond) GenServer.call( @name, @@ -147,12 +151,6 @@ defmodule X3m.System.Scheduler do ) end - @spec _now() :: DateTime.t() - defp _now() do - {:ok, time} = DateTime.now("Etc/UTC", Tzdata.TimeZoneDatabase) - time - end - @impl GenServer @doc false def init(client_state) do @@ -211,7 +209,9 @@ defmodule X3m.System.Scheduler do scheduled_alarms = alarms |> Enum.reduce(%{}, fn %Message{} = msg, acc -> - dispatch_in_ms = DateTime.diff(msg.assigns.dispatch_at, _now(), :millisecond) + dispatch_in_ms = + msg.assigns.dispatch_at + |> DateTime.diff(DateTime.utc_now(), :millisecond) cond do dispatch_in_ms < 0 -> diff --git a/mix.exs b/mix.exs index 86c8342..0e7ba16 100644 --- a/mix.exs +++ b/mix.exs @@ -94,8 +94,6 @@ defmodule X3m.System.MixProject do {:telemetry, "~> 0.4 or ~> 1.0"}, # needed when working with aggregates {:elixir_uuid, "~> 1.2", optional: true}, - # needed for use of X3m.System.Scheduller - {:tzdata, "~> 1.0", optional: true}, # test dependencies {:local_cluster, "~> 2.0", only: [:test], runtime: false}, diff --git a/mix.lock b/mix.lock index 5a038f1..a27de5b 100644 --- a/mix.lock +++ b/mix.lock @@ -1,25 +1,16 @@ %{ - "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, - "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, - "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, + "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, "global_flags": {:hex, :global_flags, "1.0.0", "ee6b864979a1fb38d1fbc67838565644baf632212bce864adca21042df036433", [:rebar3], [], "hexpm", "85d944cecd0f8f96b20ce70b5b16ebccedfcd25e744376b131e89ce61ba93176"}, - "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "local_cluster": {:hex, :local_cluster, "2.1.0", "1c847d69a927ef5a62db13236f93146e8a42377a9c9a5bb4cac3372cba69d683", [:mix], [{:global_flags, "~> 1.0", [hex: :global_flags, repo: "hexpm", optional: false]}], "hexpm", "dc1c3abb6fef00198dd53c855b39ea80c55b3a8059d8d9f17d50da46b1e3b858"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, - "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, } From 45b4760c2aaabbac4873003f9ff569b057ffbc1a Mon Sep 17 00:00:00 2001 From: Burmaja Milan Date: Tue, 9 Jun 2026 11:23:28 +0100 Subject: [PATCH 5/9] Add aggregate test example to docs --- guides/aggregates-and-event-sourcing.md | 83 ++++++++++++++++++++++++- lib/aggregate.ex | 22 ++++--- mix.exs | 1 + 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/guides/aggregates-and-event-sourcing.md b/guides/aggregates-and-event-sourcing.md index 2c4b93d..cfb6465 100644 --- a/guides/aggregates-and-event-sourcing.md +++ b/guides/aggregates-and-event-sourcing.md @@ -89,8 +89,14 @@ defmodule MyApp.Accounts.Commands.Open do alias X3m.System.Message, as: SysMsg - def new(%SysMsg{raw_request: %{"id" => id, "owner" => owner}} = msg, _state) do - SysMsg.put_request(%__MODULE__{id: id, owner: owner}, msg) + def new(%SysMsg{raw_request: raw} = msg, _state) do + cmd = %__MODULE__{ + id: raw["id"], + owner: raw["owner"], + valid?: is_binary(raw["id"]) and is_binary(raw["owner"]) + } + + SysMsg.put_request(cmd, msg) end end ``` @@ -208,6 +214,79 @@ Now a dispatch flows end to end: #=> %X3m.System.Message{response: {:created, "acc-1", 0}, ...} ``` +## Testing the aggregate + +Because an aggregate is just a module — no process, no event store, no dispatch — you +test it by calling its command functions directly and replaying the events they return. +Build a starting state with `X3m.System.Aggregate.initial_state/1`, call the command +function generated by `handle_msg` (`open_account/2`, `deposit/2`, …), and assert on the +`{:block | :noblock, message, _state}` tuple: + +```elixir +defmodule MyApp.Accounts.AggregateTest do + use ExUnit.Case, async: true + + alias MyApp.Accounts.{Aggregate, Events, State} + alias X3m.System.Message, as: SysMsg + + defp message(service, raw_request), + do: SysMsg.new(service, raw_request: raw_request) + + test "open_account emits Opened and responds :created" do + state = X3m.System.Aggregate.initial_state(Aggregate) + msg = message(:open_account, %{"id" => "acc-1", "owner" => "Ada"}) + + assert {:block, %SysMsg{events: events, response: {:created, "acc-1"}}, _state} = + Aggregate.open_account(msg, state) + + assert [%Events.Opened{id: "acc-1", owner: "Ada"}] = events + + # replay the events to get (and assert) the resulting state + assert %{client_state: %State{status: :open, owner: "Ada"}} = + Aggregate.apply_events(events, 0, state) + end + + test "open_account with a missing owner is a validation error" do + state = X3m.System.Aggregate.initial_state(Aggregate) + msg = message(:open_account, %{"id" => "acc-1"}) + + assert {:noblock, + %SysMsg{events: [], response: {:validation_error, _request}, halted?: true}, + ^state} = Aggregate.open_account(msg, state) + end + + test "deposit adds to the balance of an open account" do + # start from an already-opened account by replaying its events + state = X3m.System.Aggregate.initial_state(Aggregate) + open = message(:open_account, %{"id" => "acc-1", "owner" => "Ada"}) + {:block, %SysMsg{events: opened}, _} = Aggregate.open_account(open, state) + state = Aggregate.apply_events(opened, 0, state) + + deposit = message(:deposit, %{"account_id" => "acc-1", "amount" => 100}) + + assert {:block, %SysMsg{events: events, response: :ok}, _state} = + Aggregate.deposit(deposit, state) + + assert [%Events.Deposited{amount: 100}] = events + assert %{client_state: %State{balance: 100}} = Aggregate.apply_events(events, 1, state) + end +end +``` + +A few things to note: + +- The functions you call (`open_account/2`, `deposit/2`) are the ones `handle_msg` + generates — named after the message and taking `(message, state)`. +- A failed validation comes back as `{:noblock, message, state}` with `message.events == + []`, `halted?: true` and a `{:validation_error, request}` response (authorization + guards look the same with an `{:error, _}` response) — no setup required to assert. +- Calling a successful handler returns the events but does **not** apply them; state + changes happen in `apply_event/2` at commit time. Replaying the returned events with + `apply_events(events, version, state)` both gives you the resulting state to assert on + and exercises your `apply_event/2` clauses. +- No message handler, repo, or running process is involved, so these are plain, fast, + `async: true` unit tests. + ## Validating without persisting (`dry_run`) `X3m.System.Dispatcher.validate/1` dispatches the command with `dry_run: true`: the diff --git a/lib/aggregate.ex b/lib/aggregate.ex index e8b7762..e5d223d 100644 --- a/lib/aggregate.ex +++ b/lib/aggregate.ex @@ -53,10 +53,14 @@ defmodule X3m.System.Aggregate do """ defmodule State do - @moduledoc !""" - Internal. Wraps an aggregate's client state with its version and the set of - already-processed message ids. - """ + @moduledoc """ + The wrapper state of an aggregate: your aggregate's own state (`client_state`) plus + its event-stream `version` and the set of already-processed message ids. + + You meet it mostly in tests — `X3m.System.Aggregate.initial_state/1` returns it and + `apply_events/3` produces it — where you read `client_state` to assert on your + aggregate's state. + """ @type t :: %__MODULE__{version: integer, client_state: any} defstruct version: -1, client_state: nil, processed_messages: MapSet.new() end @@ -66,9 +70,13 @@ defmodule X3m.System.Aggregate do """ @callback initial_state :: map() - # Wraps `aggregate_mod`'s `initial_state/0` in the internal `State` struct. Used by - # the aggregate process when it spawns; not part of the public API. - @doc false + @doc """ + Builds the initial wrapped `State` for `aggregate_mod`, seeding `client_state` from its + `c:initial_state/0`. + + Used by the aggregate process when it spawns, and handy in tests as the starting state + you pass to a command function. + """ @spec initial_state(aggregate_mod :: module()) :: State.t() def initial_state(aggregate_mod), do: %State{client_state: apply(aggregate_mod, :initial_state, [])} diff --git a/mix.exs b/mix.exs index 0e7ba16..97fa52b 100644 --- a/mix.exs +++ b/mix.exs @@ -52,6 +52,7 @@ defmodule X3m.System.MixProject do "Aggregates & Event Sourcing": [ X3m.System.MessageHandler, X3m.System.Aggregate, + X3m.System.Aggregate.State, X3m.System.Aggregate.Repo ], Scheduling: [ From 5fa9279be1c48088edaa34f37bb6768c627b5d99 Mon Sep 17 00:00:00 2001 From: Burmaja Milan Date: Tue, 9 Jun 2026 19:51:09 +0100 Subject: [PATCH 6/9] Test messaging --- README.md | 10 ++ coveralls.json | 2 +- guides/aggregates-and-event-sourcing.md | 33 ++++++ guides/distribution.md | 14 +++ guides/messaging.md | 18 +++ guides/scheduling.md | 13 +++ lib/message.ex | 145 +++++++++++++++++++++++- test/dispatcher_test.exs | 19 ++++ test/message_test.exs | 87 ++++++++++++++ test/router_test.exs | 6 +- test/support/router.ex | 4 + 11 files changed, 346 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 46ea038..64001c1 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,16 @@ message to the service by name: #=> %X3m.System.Message{response: {:ok, "Hello, Ada!"}, ...} ``` +```mermaid +flowchart LR + C[Caller] -->|"Message.new(:greet)"| D[Dispatcher.dispatch] + D -->|find a node offering :greet| R[Router] + R -->|"authorize/1"| A{authorized?} + A -->|no| F["response: {:error, :forbidden}"] + A -->|yes| H["Greeter.greet/1"] + H -->|"{:reply, Message.ok(...)}"| C +``` + No aggregates or event store are involved here — any module registered through a router can be a dispatch target. diff --git a/coveralls.json b/coveralls.json index 90c3795..7dd2f77 100644 --- a/coveralls.json +++ b/coveralls.json @@ -1,6 +1,6 @@ { "coverage_options": { - "minimum_coverage": 37.0 + "minimum_coverage": 46.0 }, "skip_files": [ "test/support" diff --git a/guides/aggregates-and-event-sourcing.md b/guides/aggregates-and-event-sourcing.md index cfb6465..98f322e 100644 --- a/guides/aggregates-and-event-sourcing.md +++ b/guides/aggregates-and-event-sourcing.md @@ -19,6 +19,39 @@ dispatcher. | `X3m.System.Aggregate.Repo` | reads and writes the event stream (you implement it) | | `X3m.System.Router` | routes a service call to the message handler | +How they collaborate when a command is dispatched — either the aggregate is already in +memory, or it must be rehydrated from its event stream first — then the new events are +persisted: + +```mermaid +sequenceDiagram + participant D as Dispatcher + participant R as Router + participant MH as MessageHandler + participant Repo as Aggregate.Repo + participant Agg as Aggregate + D->>R: command message + R->>R: authorize/1 + R->>MH: command message + alt aggregate already in memory (pid registered) + MH->>Agg: handle_msg(message, current state) + else not running (when_pid_is_not_registered/3) + MH->>Repo: load event stream + Repo-->>MH: past events + MH->>Agg: apply_event/2 per event (rebuild state) + MH->>Agg: handle_msg(message, rebuilt state) + end + Agg-->>MH: {:block, message + events, state} + Note over Agg: blocked — no further commands until commit + apply + MH->>Repo: save_events + MH->>Agg: apply_event/2 for new events (new state), then unblock + MH-->>D: response, e.g. {:created, id, version} +``` + +With `:block`, the aggregate process holds further commands until the message handler has +committed and applied these events — so commands are serialized per aggregate and never run +against not-yet-persisted state. (`:noblock` returns immediately; nothing is persisted.) + ## 1. The aggregate `use X3m.System.Aggregate` and declare the starting state, one command handler per diff --git a/guides/distribution.md b/guides/distribution.md index 8d1031b..9530ff5 100644 --- a/guides/distribution.md +++ b/guides/distribution.md @@ -68,6 +68,20 @@ When `choose_node/1` returns a remote node, the router forwards the call there; node runs the handler and replies **directly** to the original caller — the response does not hop back through the node that received the request. +```mermaid +sequenceDiagram + participant Caller + participant R1 as Router (receiving node) + participant R2 as Router (owner node) + participant H as Service handler + Caller->>R1: dispatch(message) + R1->>R1: choose_node/1 -> owner node + R1->>R2: _invoke via rpc (forward) + R2->>H: invoke service function + H-->>R2: {:reply, message} + R2-->>Caller: send to message.reply_to (directly, not via R1) +``` + ## Asking for another node Sometimes a node accepts a call but then realises it can't serve it (for example, a diff --git a/guides/messaging.md b/guides/messaging.md index 0da925f..15a0838 100644 --- a/guides/messaging.md +++ b/guides/messaging.md @@ -111,6 +111,24 @@ and waits for the reply: |> X3m.System.Dispatcher.dispatch(timeout: 10_000) ``` +```mermaid +sequenceDiagram + participant Caller + participant Dispatcher + participant Registry as Service registry + participant Handler as Service handler + Caller->>Dispatcher: dispatch(message) + Dispatcher->>Registry: which node offers service_name? + alt no provider + Registry-->>Dispatcher: none + Dispatcher-->>Caller: response {:service_unavailable, name} + else provider found + Registry-->>Dispatcher: node + Dispatcher->>Handler: authorize/1, then invoke + Handler-->>Caller: %Message{response: ...} + end +``` + - The default timeout is 5000 ms; on expiry the response becomes `{:service_timeout, service_name, message_id, timeout}`. - If no node offers the service, the response is diff --git a/guides/scheduling.md b/guides/scheduling.md index f51d60d..a1cbef0 100644 --- a/guides/scheduling.md +++ b/guides/scheduling.md @@ -64,6 +64,19 @@ The scheduler assigns the resolved `:dispatch_at` onto the message, calls your `save_alarm/3` so it is persisted, and arms delivery. When the time arrives the message is sent through `X3m.System.Dispatcher.dispatch/2` to its `service_name`. +```mermaid +flowchart TD + S["Scheduler.dispatch(msg, id, at:/in:)"] --> SA["save_alarm/3 (persist)"] + SA --> WIN{"due within the in-memory window?"} + WIN -->|"yes: loaded by load_alarms/3"| ARM["armed in memory"] + WIN -->|"far future"| LATER["stays persisted; loaded later / after restart"] + LATER -.->|"window reached"| ARM + ARM -->|"time arrives"| DISP["Dispatcher.dispatch/2"] + DISP --> SR["service_responded/2"] + SR -->|":ok"| DONE["delete alarm"] + SR -->|"{:retry, in_ms, msg}"| ARM +``` + ## Persistence and the in-memory window Not every future alarm is kept in memory. The scheduler loads alarms in bulk every diff --git a/lib/message.ex b/lib/message.ex index b3ee3b3..23770bb 100644 --- a/lib/message.ex +++ b/lib/message.ex @@ -20,8 +20,9 @@ defmodule X3m.System.Message do * `response` - the response for invoker. * `events` - list of generated events. * `aggregate_meta` - metadata for aggregate. - * `valid?` - when set to `true` it means that raw_request was successfully validated and - structered request is set to `request` field + * `valid?` - `true` by default on a new message; set to `false` by `put_request/2` + when the structured request fails validation. It means "not known to be invalid" + rather than "already validated". * `origin_node` - Node.self() of invoker * `reply_to` - Pid of process that is waiting for response. * `halted?` - when set to `true` it means that response should be returned to the invoker @@ -68,6 +69,14 @@ defmodule X3m.System.Message do * `reply_to` - sets pid of process that expects response. If not provided it is set to `self()`. * `raw_request` - sets raw request as it is received (i.e. `params` from controller action). * `logger_metadata` - if not provided `Logger.metadata` is used by default. + + ## Examples + + iex> msg = X3m.System.Message.new(:open_account, raw_request: %{"id" => "acc-1"}) + iex> {msg.service_name, msg.raw_request, msg.valid?, msg.halted?} + {:open_account, %{"id" => "acc-1"}, true, false} + iex> msg.correlation_id == msg.id and msg.causation_id == msg.id + true """ @spec new(atom, Keyword.t()) :: __MODULE__.t() def new(service_name, opts \\ []) when is_atom(service_name) do @@ -102,6 +111,18 @@ defmodule X3m.System.Message do @doc """ Creates new message with given `service_name` that is caused by other `msg`. + + The child keeps the parent's `correlation_id` (the id of the message that *started* + the conversation) and sets its `causation_id` to the parent's `id`. + + ## Examples + + iex> parent = X3m.System.Message.new(:open_account) + iex> child = X3m.System.Message.new_caused_by(:notify_owner, parent) + iex> {child.service_name, child.correlation_id == parent.correlation_id, child.causation_id == parent.id} + {:notify_owner, true, true} + iex> child.id == parent.id + false """ @spec new_caused_by(atom, __MODULE__.t(), Keyword.t()) :: __MODULE__.t() def new_caused_by(service_name, %__MODULE__{} = msg, opts \\ []) when is_atom(service_name) do @@ -118,6 +139,13 @@ defmodule X3m.System.Message do Returns `sys_msg` re-targeted at a different `service_name`, leaving its ids, payload and assigns intact. Useful for re-dispatching the same request to another service. + + ## Examples + + iex> msg = X3m.System.Message.new(:open_account, id: "m-1", raw_request: %{"id" => "acc-1"}) + iex> retargeted = X3m.System.Message.to_service(msg, :close_account) + iex> {retargeted.service_name, retargeted.id, retargeted.raw_request} + {:close_account, "m-1", %{"id" => "acc-1"}} """ @spec to_service(t(), service_name :: atom) :: t() def to_service(%__MODULE__{} = sys_msg, service_name), @@ -144,6 +172,17 @@ defmodule X3m.System.Message do @doc """ Returns `sys_msg` with provided `response` and as `halted? = true`. + + Events accumulated with `add_event/2` are reversed back into the order they were + added. + + ## Examples + + iex> alias X3m.System.{Message, Response} + iex> msg = Message.new(:open_account) |> Message.add_event(:opened) |> Message.add_event(:limit_set) + iex> returned = Message.return(msg, Response.ok(:done)) + iex> {returned.response, returned.halted?, returned.events} + {{:ok, :done}, true, [:opened, :limit_set]} """ @spec return(__MODULE__.t(), Response.t()) :: __MODULE__.t() def return(%__MODULE__{events: events} = sys_msg, response) do @@ -155,6 +194,12 @@ defmodule X3m.System.Message do @doc """ Returns `message` it received with `Response.created(id)` result set. + + ## Examples + + iex> msg = X3m.System.Message.new(:open_account) |> X3m.System.Message.created("acc-1") + iex> {msg.response, msg.halted?} + {{:created, "acc-1"}, true} """ @spec created(__MODULE__.t(), any) :: __MODULE__.t() def created(%__MODULE__{} = message, id) do @@ -162,18 +207,47 @@ defmodule X3m.System.Message do return(message, response) end + @doc """ + Returns `message` carrying `Response.ok/0`'s bare `:ok` result, with `halted? = true`. + + ## Examples + + iex> msg = X3m.System.Message.new(:ping) |> X3m.System.Message.ok() + iex> {msg.response, msg.halted?} + {:ok, true} + """ @spec ok(__MODULE__.t()) :: __MODULE__.t() def ok(message) do response = Response.ok() return(message, response) end + @doc """ + Returns `message` with a `Response.ok/1` result wrapping the given value, with + `halted? = true`. + + ## Examples + + iex> msg = X3m.System.Message.new(:get_account) |> X3m.System.Message.ok(%{balance: 10}) + iex> {msg.response, msg.halted?} + {{:ok, %{balance: 10}}, true} + """ @spec ok(__MODULE__.t(), any) :: __MODULE__.t() def ok(message, any) do response = Response.ok(any) return(message, response) end + @doc """ + Returns `message` with a `Response.error/1` result wrapping the given reason, with + `halted? = true`. + + ## Examples + + iex> msg = X3m.System.Message.new(:open_account) |> X3m.System.Message.error(:not_found) + iex> {msg.response, msg.halted?} + {{:error, :not_found}, true} + """ @spec error(__MODULE__.t(), any) :: __MODULE__.t() def error(message, any) do response = Response.error(any) @@ -187,6 +261,22 @@ defmodule X3m.System.Message do If `request` carries `valid?: false`, the message is halted with a `Response.validation_error/1` so dispatch returns the error immediately. Otherwise the request is stored and `message.valid?` is set to `true`. + + ## Examples + + A valid request is stored and the message stays open: + + iex> msg = X3m.System.Message.new(:open_account) + iex> msg = X3m.System.Message.put_request(%{owner: "Ada"}, msg) + iex> {msg.valid?, msg.request, msg.halted?} + {true, %{owner: "Ada"}, false} + + An invalid request (e.g. an invalid `Ecto.Changeset`) halts with a validation error: + + iex> msg = X3m.System.Message.new(:open_account) + iex> msg = X3m.System.Message.put_request(%{valid?: false}, msg) + iex> {msg.valid?, msg.halted?, msg.response} + {false, true, {:validation_error, %{valid?: false}}} """ @spec put_request(request :: map(), t()) :: t() def put_request(%{valid?: false} = request, %__MODULE__{} = message) do @@ -199,6 +289,18 @@ defmodule X3m.System.Message do @doc """ Puts `value` under `key` in `message.raw_request` map. + + ## Examples + + iex> msg = X3m.System.Message.new(:open_account, raw_request: %{"id" => "acc-1"}) + iex> X3m.System.Message.put_in_raw_request(msg, "owner", "Ada").raw_request + %{"id" => "acc-1", "owner" => "Ada"} + + When `raw_request` is `nil` it is treated as an empty map: + + iex> msg = X3m.System.Message.new(:open_account) + iex> X3m.System.Message.put_in_raw_request(msg, :owner, "Ada").raw_request + %{owner: "Ada"} """ @spec put_in_raw_request(t(), key :: term(), value :: term()) :: t() def put_in_raw_request(%__MODULE__{} = message, key, value) do @@ -215,6 +317,22 @@ defmodule X3m.System.Message do After `return/2` (and friends) order of `msg.events` will be the same as they've been added. + + ## Examples + + Events are prepended, so they accumulate in reverse-insertion order until `return/2` + reverses them back into the order they were added: + + iex> msg = X3m.System.Message.new(:open_account) + iex> msg = msg |> X3m.System.Message.add_event(:opened) |> X3m.System.Message.add_event(:limit_set) + iex> msg.events + [:limit_set, :opened] + + A `nil` event is ignored: + + iex> msg = X3m.System.Message.new(:open_account) + iex> X3m.System.Message.add_event(msg, nil).events + [] """ @spec add_event(message :: t(), event :: nil | any) :: t() def add_event(%__MODULE__{} = message, nil), @@ -235,6 +353,29 @@ defmodule X3m.System.Message do When the id is missing and `:generate_if_missing` is `false` (the default), the message is halted with a `Response.missing_id/1` response. This is what the `X3m.System.MessageHandler` `on_new_aggregate` / `on_aggregate` macros call for you. + + ## Examples + + When the id is present under `id_field` it is copied into `aggregate_meta`: + + iex> msg = X3m.System.Message.new(:open_account, raw_request: %{"id" => "acc-1"}) + iex> X3m.System.Message.prepare_aggregate_id(msg, "id").aggregate_meta.id + "acc-1" + + When it is missing and `:generate_if_missing` is not set, the message is halted: + + iex> msg = X3m.System.Message.new(:open_account, raw_request: %{}) + iex> msg = X3m.System.Message.prepare_aggregate_id(msg, "id") + iex> {msg.halted?, msg.response} + {true, {:missing_id, "id"}} + + With `generate_if_missing: true` a fresh id is written into both `raw_request` and + `aggregate_meta`: + + iex> msg = X3m.System.Message.new(:open_account, raw_request: %{}) + iex> msg = X3m.System.Message.prepare_aggregate_id(msg, "id", generate_if_missing: true) + iex> is_binary(msg.aggregate_meta.id) and msg.raw_request["id"] == msg.aggregate_meta.id + true """ @spec prepare_aggregate_id(t(), id_field :: term(), opts :: Keyword.t()) :: t() def prepare_aggregate_id(%__MODULE__{} = message, id_field, opts \\ []) do diff --git a/test/dispatcher_test.exs b/test/dispatcher_test.exs index 62364b2..d51726f 100644 --- a/test/dispatcher_test.exs +++ b/test/dispatcher_test.exs @@ -284,6 +284,25 @@ defmodule X3m.System.DispatcherTest do end end + describe "authorize/1 by assigns" do + test "authorizes and dispatches when assigns carry the admin marker" do + msg = + :admin_only + |> Message.new() + |> Message.assign(:invoked_by, %{admin?: true}) + + assert Dispatcher.authorized?(msg) == true + assert %Message{response: {:ok, :from_first}} = Dispatcher.dispatch(msg) + end + + test "denies by default when the admin marker is absent" do + msg = Message.new(:admin_only) + + assert Dispatcher.authorized?(msg) == false + assert %Message{response: {:error, :forbidden}} = Dispatcher.dispatch(msg) + end + end + defp _new_message(service_name) do Message.new(service_name, raw_request: %{test_pid: self()}) end diff --git a/test/message_test.exs b/test/message_test.exs index 4e04dc7..530e48d 100644 --- a/test/message_test.exs +++ b/test/message_test.exs @@ -1,4 +1,91 @@ defmodule X3m.System.MessageTest do use ExUnit.Case, async: true + alias X3m.System.{Message, Response} + doctest X3m.System.Message + + describe "gen_msg_id/0" do + test "produces distinct ids" do + ids = for _ <- 1..100, do: Message.gen_msg_id() + assert length(Enum.uniq(ids)) == 100 + end + + test "produces url-safe ids" do + assert Message.gen_msg_id() =~ ~r/^[A-Za-z0-9_-]+$/ + end + end + + describe "new/2 defaults" do + test "reply_to defaults to the calling process" do + assert Message.new(:svc).reply_to == self() + end + + test "correlation_id and causation_id fall back to id" do + msg = Message.new(:svc, id: "m-1") + assert msg.correlation_id == "m-1" + assert msg.causation_id == "m-1" + end + + test "dry_run is false, valid? true, halted? false by default" do + msg = Message.new(:svc) + assert msg.dry_run == false + assert msg.valid? == true + assert msg.halted? == false + end + + test "captures current Logger.metadata" do + Logger.metadata(trace_id: "t-1") + assert Message.new(:svc).logger_metadata[:trace_id] == "t-1" + end + end + + describe "new_caused_by/3" do + test "preserves correlation_id and sets causation_id to the parent id" do + # distinct id and correlation_id so the two invariants can't be conflated: + # correlation_id threads through the whole conversation, causation_id points + # at the direct parent. + parent = Message.new(:open_account, id: "p-1", correlation_id: "c-1") + child = Message.new_caused_by(:notify_owner, parent) + assert child.correlation_id == "c-1" + assert child.causation_id == "p-1" + refute child.id == "p-1" + end + + test "passes raw_request through" do + parent = Message.new(:open_account) + child = Message.new_caused_by(:notify_owner, parent, raw_request: %{"x" => 1}) + assert child.raw_request == %{"x" => 1} + end + end + + describe "return/2" do + test "reverses accumulated events into insertion order and halts" do + returned = + :svc + |> Message.new() + |> Message.add_event(:first) + |> Message.add_event(:second) + |> Message.return(Response.ok()) + + assert returned.events == [:first, :second] + assert returned.halted? == true + assert returned.response == :ok + end + end + + describe "put_request/2" do + test "stores a valid request and keeps the message open" do + msg = Message.put_request(%{owner: "Ada"}, Message.new(:open_account)) + assert msg.valid? == true + assert msg.request == %{owner: "Ada"} + assert msg.halted? == false + end + + test "halts with a validation_error when the request is invalid" do + msg = Message.put_request(%{valid?: false}, Message.new(:open_account)) + assert msg.valid? == false + assert msg.halted? == true + assert msg.response == {:validation_error, %{valid?: false}} + end + end end diff --git a/test/router_test.exs b/test/router_test.exs index 60a513b..2b98dd4 100644 --- a/test/router_test.exs +++ b/test/router_test.exs @@ -9,7 +9,8 @@ defmodule X3m.System.RouterTest do first: 1, unauthorized_service: 1, custom_unauthorized_service: 1, - try_another_node: 1 + try_another_node: 1, + admin_only: 1 ] = Router.registered_services(:public) end @@ -24,7 +25,8 @@ defmodule X3m.System.RouterTest do first: 1, unauthorized_service: 1, custom_unauthorized_service: 1, - try_another_node: 1 + try_another_node: 1, + admin_only: 1 ] = Router.registered_services(:all) end end diff --git a/test/support/router.ex b/test/support/router.ex index 12149b9..6db46b3 100644 --- a/test/support/router.ex +++ b/test/support/router.ex @@ -8,6 +8,7 @@ defmodule X3m.System.Test.Router do service :unauthorized_service, Controller, :first service :custom_unauthorized_service, Controller, :first service :try_another_node, Controller + service :admin_only, Controller, :first servicep :private_service, Controller, :private @@ -15,6 +16,9 @@ defmodule X3m.System.Test.Router do def authorize(%SysMsg{service_name: :private_service}), do: :ok def authorize(%SysMsg{service_name: :try_another_node}), do: :ok + def authorize(%SysMsg{service_name: :admin_only, assigns: %{invoked_by: %{admin?: true}}}), + do: :ok + def authorize(%SysMsg{service_name: :custom_unauthorized_service}), do: {:forbidden, "None shall pass!"} end From 2cd95343b10e9a93a68d878648b908176638d52f Mon Sep 17 00:00:00 2001 From: Burmaja Milan Date: Tue, 9 Jun 2026 23:36:09 +0100 Subject: [PATCH 7/9] Add Aggregate.TestSupport and more documentation for aggregates --- coveralls.json | 2 +- guides/aggregates-and-event-sourcing.md | 42 +++-- lib/aggregate/test_support.ex | 71 ++++++++ mix.exs | 6 +- mix.lock | 2 + test/account_aggregate_test.exs | 196 ++++++++++++++++++++++ test/aggregate/test_support_test.exs | 62 +++++++ test/support/account/aggregate.ex | 75 +++++++++ test/support/account/command_helpers.ex | 13 ++ test/support/account/commands/deposit.ex | 28 ++++ test/support/account/commands/open.ex | 20 +++ test/support/account/commands/withdraw.ex | 21 +++ test/support/account/events.ex | 8 + test/support/account/state.ex | 4 + 14 files changed, 530 insertions(+), 20 deletions(-) create mode 100644 lib/aggregate/test_support.ex create mode 100644 test/account_aggregate_test.exs create mode 100644 test/aggregate/test_support_test.exs create mode 100644 test/support/account/aggregate.ex create mode 100644 test/support/account/command_helpers.ex create mode 100644 test/support/account/commands/deposit.ex create mode 100644 test/support/account/commands/open.ex create mode 100644 test/support/account/commands/withdraw.ex create mode 100644 test/support/account/events.ex create mode 100644 test/support/account/state.ex diff --git a/coveralls.json b/coveralls.json index 7dd2f77..5458c91 100644 --- a/coveralls.json +++ b/coveralls.json @@ -1,6 +1,6 @@ { "coverage_options": { - "minimum_coverage": 46.0 + "minimum_coverage": 47.0 }, "skip_files": [ "test/support" diff --git a/guides/aggregates-and-event-sourcing.md b/guides/aggregates-and-event-sourcing.md index 98f322e..d029e32 100644 --- a/guides/aggregates-and-event-sourcing.md +++ b/guides/aggregates-and-event-sourcing.md @@ -251,37 +251,38 @@ Now a dispatch flows end to end: Because an aggregate is just a module — no process, no event store, no dispatch — you test it by calling its command functions directly and replaying the events they return. -Build a starting state with `X3m.System.Aggregate.initial_state/1`, call the command -function generated by `handle_msg` (`open_account/2`, `deposit/2`, …), and assert on the -`{:block | :noblock, message, _state}` tuple: +`X3m.System.Aggregate.TestSupport` gives you the given/when/then pieces: +`command_message/3` builds the command message, `state_from_events/3` rebuilds the +*given* state from prior events, and `apply_events/4` folds the events a command *emitted* +back onto that state so you can assert the result: ```elixir defmodule MyApp.Accounts.AggregateTest do use ExUnit.Case, async: true + import X3m.System.Aggregate.TestSupport alias MyApp.Accounts.{Aggregate, Events, State} alias X3m.System.Message, as: SysMsg - defp message(service, raw_request), - do: SysMsg.new(service, raw_request: raw_request) - test "open_account emits Opened and responds :created" do + # given state = X3m.System.Aggregate.initial_state(Aggregate) - msg = message(:open_account, %{"id" => "acc-1", "owner" => "Ada"}) + # when + msg = command_message(:open_account, %{"id" => "acc-1", "owner" => "Ada"}) assert {:block, %SysMsg{events: events, response: {:created, "acc-1"}}, _state} = Aggregate.open_account(msg, state) assert [%Events.Opened{id: "acc-1", owner: "Ada"}] = events - # replay the events to get (and assert) the resulting state + # then — replay the emitted events to assert the resulting state assert %{client_state: %State{status: :open, owner: "Ada"}} = - Aggregate.apply_events(events, 0, state) + apply_events(Aggregate, state, events) end test "open_account with a missing owner is a validation error" do state = X3m.System.Aggregate.initial_state(Aggregate) - msg = message(:open_account, %{"id" => "acc-1"}) + msg = command_message(:open_account, %{"id" => "acc-1"}) assert {:noblock, %SysMsg{events: [], response: {:validation_error, _request}, halted?: true}, @@ -289,19 +290,18 @@ defmodule MyApp.Accounts.AggregateTest do end test "deposit adds to the balance of an open account" do - # start from an already-opened account by replaying its events - state = X3m.System.Aggregate.initial_state(Aggregate) - open = message(:open_account, %{"id" => "acc-1", "owner" => "Ada"}) - {:block, %SysMsg{events: opened}, _} = Aggregate.open_account(open, state) - state = Aggregate.apply_events(opened, 0, state) + # given an already-opened account + state = state_from_events(Aggregate, [%Events.Opened{id: "acc-1", owner: "Ada"}]) - deposit = message(:deposit, %{"account_id" => "acc-1", "amount" => 100}) + # when + deposit = command_message(:deposit, %{"account_id" => "acc-1", "amount" => 100}) assert {:block, %SysMsg{events: events, response: :ok}, _state} = Aggregate.deposit(deposit, state) + # then assert [%Events.Deposited{amount: 100}] = events - assert %{client_state: %State{balance: 100}} = Aggregate.apply_events(events, 1, state) + assert %{client_state: %State{balance: 100}} = apply_events(Aggregate, state, events) end end ``` @@ -310,6 +310,14 @@ A few things to note: - The functions you call (`open_account/2`, `deposit/2`) are the ones `handle_msg` generates — named after the message and taking `(message, state)`. +- `command_message/3` builds the message (with `raw_request` and `assigns`), + `state_from_events/3` is the *given* state, and `apply_events/4` is the *then* step. +- A command's validate function receives `(message, state)`, so it can validate against + state — e.g. rejecting a deposit into a closed account with a `validation_error`. +- A command may emit events *and* return an error (`{:block, error, state}`) — e.g. + recording a rejection while replying `{:error, _}` — and a single command may emit + several events (closing an account can return the remaining balance and then close it). + So assert the emitted events, not just the response. - A failed validation comes back as `{:noblock, message, state}` with `message.events == []`, `halted?: true` and a `{:validation_error, request}` response (authorization guards look the same with an `{:error, _}` response) — no setup required to assert. diff --git a/lib/aggregate/test_support.ex b/lib/aggregate/test_support.ex new file mode 100644 index 0000000..88a35ec --- /dev/null +++ b/lib/aggregate/test_support.ex @@ -0,0 +1,71 @@ +defmodule X3m.System.Aggregate.TestSupport do + @moduledoc """ + Helpers for testing aggregates as pure functions, in a given/when/then style. + + An aggregate command is a plain function + `cmd(X3m.System.Message.t(), X3m.System.Aggregate.State.t())` returning + `{:block | :noblock, message, state}`. To test one: + + * **given** - `state_from_events/3` rebuilds the state from prior events; + * **when** - `command_message/3` builds the message; you call the command; + * **then** - `apply_events/4` folds the events the command emitted onto the given + state so you can assert the resulting `client_state`. + + No aggregate process or event store is involved. + """ + alias X3m.System.{Aggregate, Message} + + @doc """ + Builds a `X3m.System.Message` for `service_name`, with `raw_request` and each entry of + `assigns` applied via `X3m.System.Message.assign/3`. + + ## Examples + + iex> msg = X3m.System.Aggregate.TestSupport.command_message(:open_account, %{"id" => "a1"}, %{invoked_by: %{admin?: true}}) + iex> {msg.service_name, msg.raw_request, msg.assigns} + {:open_account, %{"id" => "a1"}, %{invoked_by: %{admin?: true}}} + """ + @spec command_message(service_name :: atom, raw_request :: map, assigns :: map) :: Message.t() + def command_message(service_name, raw_request \\ %{}, assigns \\ %{}) do + service_name + |> Message.new(raw_request: raw_request) + |> _apply_assigns(assigns) + end + + @doc """ + Folds `events` onto an existing `state` via `aggregate_mod`'s generated `apply_events/3`. + + Options: + + * `:version` - version to stamp on the result. Defaults to + `state.version + length(events)`. + """ + @spec apply_events( + aggregate_mod :: module, + state :: Aggregate.State.t(), + events :: [any], + opts :: Keyword.t() + ) :: Aggregate.State.t() + def apply_events(aggregate_mod, %Aggregate.State{} = state, events, opts \\ []) do + version = Keyword.get(opts, :version, state.version + length(events)) + aggregate_mod.apply_events(events, version, state) + end + + @doc """ + Builds the state you'd have after `events` were applied to `aggregate_mod`'s initial + state - `apply_events/4` starting from `X3m.System.Aggregate.initial_state/1`. + + Options: + + * `:version` - version to stamp on the result. Defaults to `length(events) - 1` + (initial version `-1` plus one per event), matching a fresh stream. + """ + @spec state_from_events(aggregate_mod :: module, events :: [any], opts :: Keyword.t()) :: + Aggregate.State.t() + def state_from_events(aggregate_mod, events, opts \\ []), + do: apply_events(aggregate_mod, Aggregate.initial_state(aggregate_mod), events, opts) + + @spec _apply_assigns(Message.t(), map) :: Message.t() + defp _apply_assigns(message, assigns), + do: Enum.reduce(assigns, message, fn {key, val}, msg -> Message.assign(msg, key, val) end) +end diff --git a/mix.exs b/mix.exs index 97fa52b..6566955 100644 --- a/mix.exs +++ b/mix.exs @@ -21,7 +21,7 @@ defmodule X3m.System.MixProject do docs: _docs(), aliases: _aliases(), deps: _deps(), - dialyzer: [plt_add_apps: [:ex_unit, :local_cluster]], + dialyzer: [plt_add_apps: [:ex_unit, :local_cluster, :ecto]], elixirc_paths: _elixirc_paths(Mix.env()) ] end @@ -53,7 +53,8 @@ defmodule X3m.System.MixProject do X3m.System.MessageHandler, X3m.System.Aggregate, X3m.System.Aggregate.State, - X3m.System.Aggregate.Repo + X3m.System.Aggregate.Repo, + X3m.System.Aggregate.TestSupport ], Scheduling: [ X3m.System.Scheduler @@ -95,6 +96,7 @@ defmodule X3m.System.MixProject do {:telemetry, "~> 0.4 or ~> 1.0"}, # needed when working with aggregates {:elixir_uuid, "~> 1.2", optional: true}, + {:ecto, "~> 3.0", only: :test}, # test dependencies {:local_cluster, "~> 2.0", only: [:test], runtime: false}, diff --git a/mix.lock b/mix.lock index a27de5b..0c7fb07 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,8 @@ %{ + "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, diff --git a/test/account_aggregate_test.exs b/test/account_aggregate_test.exs new file mode 100644 index 0000000..14c5eb9 --- /dev/null +++ b/test/account_aggregate_test.exs @@ -0,0 +1,196 @@ +defmodule X3m.System.Test.AccountAggregateTest do + use ExUnit.Case, async: true + alias X3m.System.{Aggregate, Message} + alias X3m.System.Aggregate.TestSupport + alias X3m.System.Test.Account.Aggregate, as: Account + alias X3m.System.Test.Account.{State, Events} + + test "initial_state/1 wraps the account's starting client state" do + assert %Aggregate.State{version: -1, client_state: %State{balance: 0, closed?: false}} = + Aggregate.initial_state(Account) + end + + describe "open_account" do + test "emits Opened and replies :created; applied state has id and owner" do + # given + state = Aggregate.initial_state(Account) + # when + msg = TestSupport.command_message(:open_account, %{"id" => "a1", "owner_id" => "u1"}) + + assert {:block, + %Message{ + events: [%Events.Opened{id: "a1", owner_id: "u1"}] = events, + response: {:created, "a1"}, + halted?: true + }, _} = Account.open_account(msg, state) + + # then + applied = TestSupport.apply_events(Account, state, events) + assert applied.client_state.id == "a1" + assert applied.client_state.owner_id == "u1" + end + + test "halts with a validation_error when owner_id is missing" do + state = Aggregate.initial_state(Account) + msg = TestSupport.command_message(:open_account, %{"id" => "a1"}) + + assert {:noblock, + %Message{halted?: true, response: {:validation_error, _changeset}, events: []}, + ^state} = + Account.open_account(msg, state) + end + end + + describe "deposit" do + test "emits Deposited; applied state's balance grows" do + # given + state = TestSupport.state_from_events(Account, [%Events.Opened{id: "a1", owner_id: "u1"}]) + assert state.client_state.balance == 0 + # when + msg = TestSupport.command_message(:deposit, %{"id" => "a1", "amount" => 100}) + + assert {:block, %Message{events: [%Events.Deposited{amount: 100}] = events, response: :ok}, + _} = + Account.deposit(msg, state) + + # then + applied = TestSupport.apply_events(Account, state, events) + assert applied.client_state.balance == 100 + end + + test "halts with a validation_error when amount is not positive" do + state = TestSupport.state_from_events(Account, [%Events.Opened{id: "a1", owner_id: "u1"}]) + msg = TestSupport.command_message(:deposit, %{"id" => "a1", "amount" => 0}) + + assert {:noblock, %Message{halted?: true, response: {:validation_error, _}, events: []}, + ^state} = + Account.deposit(msg, state) + end + + test "halts with a validation_error when the account is closed (state-aware validation)" do + # given a closed account + state = + TestSupport.state_from_events(Account, [ + %Events.Opened{id: "a1", owner_id: "u1"}, + %Events.Closed{id: "a1"} + ]) + + assert state.client_state.closed? == true + # when / then + msg = TestSupport.command_message(:deposit, %{"id" => "a1", "amount" => 50}) + + assert {:noblock, %Message{halted?: true, response: {:validation_error, _}, events: []}, + ^state} = + Account.deposit(msg, state) + end + end + + describe "withdraw" do + test "within balance emits Withdrawn; applied balance drops" do + state = + TestSupport.state_from_events(Account, [ + %Events.Opened{id: "a1", owner_id: "u1"}, + %Events.Deposited{id: "a1", amount: 100} + ]) + + assert state.client_state.balance == 100 + msg = TestSupport.command_message(:withdraw, %{"id" => "a1", "amount" => 60}) + + assert {:block, %Message{events: [%Events.Withdrawn{amount: 60}] = events, response: :ok}, + _} = + Account.withdraw(msg, state) + + applied = TestSupport.apply_events(Account, state, events) + assert applied.client_state.balance == 40 + end + + test "beyond balance records WithdrawalRejected AND returns an error (events with error)" do + state = + TestSupport.state_from_events(Account, [ + %Events.Opened{id: "a1", owner_id: "u1"}, + %Events.Deposited{id: "a1", amount: 100} + ]) + + assert state.client_state.balance == 100 + msg = TestSupport.command_message(:withdraw, %{"id" => "a1", "amount" => 150}) + + assert {:block, + %Message{ + events: [%Events.WithdrawalRejected{amount: 150, balance: 100}] = events, + response: {:error, :insufficient_funds} + }, _} = Account.withdraw(msg, state) + + # the rejection is recorded in state; balance is untouched + applied = TestSupport.apply_events(Account, state, events) + assert applied.client_state.balance == 100 + assert applied.client_state.highest_rejected_withdrawal == 150 + end + end + + describe "close_account (instance authorization, multi-event)" do + setup do + # an account owned by "u1" holding 100 + state = + TestSupport.state_from_events(Account, [ + %Events.Opened{id: "a1", owner_id: "u1"}, + %Events.Deposited{id: "a1", amount: 100} + ]) + + assert state.client_state.owner_id == "u1" + assert state.client_state.balance == 100 + {:ok, state: state} + end + + test "an admin may close; it returns the balance then closes", %{state: state} do + msg = TestSupport.command_message(:close_account, %{}, %{invoked_by: %{admin?: true}}) + + assert {:block, + %Message{ + events: [%Events.Withdrawn{amount: 100}, %Events.Closed{}] = events, + response: :ok + }, _} = Account.close_account(msg, state) + + applied = TestSupport.apply_events(Account, state, events) + assert applied.client_state.balance == 0 + assert applied.client_state.closed? == true + end + + test "the owner may close their own account", %{state: state} do + msg = TestSupport.command_message(:close_account, %{}, %{invoked_by: %{user_id: "u1"}}) + + assert {:block, + %Message{ + events: [%Events.Withdrawn{amount: 100}, %Events.Closed{}] = events, + response: :ok + }, _} = Account.close_account(msg, state) + + applied = TestSupport.apply_events(Account, state, events) + assert applied.client_state.balance == 0 + assert applied.client_state.closed? == true + end + + test "anyone else is forbidden; no event, state unchanged", %{state: state} do + msg = TestSupport.command_message(:close_account, %{}, %{invoked_by: %{user_id: "u2"}}) + + assert {:noblock, %Message{events: [], response: {:error, :forbidden}}, ^state} = + Account.close_account(msg, state) + end + end + + describe "idempotency" do + test "a message whose id was already processed returns :ok, no events, and warns" do + state = TestSupport.state_from_events(Account, [%Events.Opened{id: "a1", owner_id: "u1"}]) + msg = TestSupport.command_message(:deposit, %{"id" => "a1", "amount" => 50}) + + processed = %Aggregate.State{ + state + | processed_messages: MapSet.put(state.processed_messages, msg.id) + } + + {result, log} = ExUnit.CaptureLog.with_log(fn -> Account.deposit(msg, processed) end) + + assert {:noblock, %Message{response: :ok, events: []}, ^processed} = result + assert log =~ "already processed" + end + end +end diff --git a/test/aggregate/test_support_test.exs b/test/aggregate/test_support_test.exs new file mode 100644 index 0000000..f0f3991 --- /dev/null +++ b/test/aggregate/test_support_test.exs @@ -0,0 +1,62 @@ +defmodule X3m.System.Aggregate.TestSupportTest do + use ExUnit.Case, async: true + alias X3m.System.Aggregate + alias X3m.System.Aggregate.TestSupport + alias X3m.System.Test.Account.Aggregate, as: Account + alias X3m.System.Test.Account.{State, Events} + + doctest X3m.System.Aggregate.TestSupport + + describe "command_message/3" do + test "builds a message with raw_request and assigns" do + msg = + TestSupport.command_message(:deposit, %{"amount" => 10}, %{invoked_by: %{user_id: "u1"}}) + + assert msg.service_name == :deposit + assert msg.raw_request == %{"amount" => 10} + assert msg.assigns == %{invoked_by: %{user_id: "u1"}} + end + + test "defaults raw_request and assigns to empty maps" do + msg = TestSupport.command_message(:ping) + assert msg.raw_request == %{} + assert msg.assigns == %{} + end + end + + describe "state_from_events/3 (the given step)" do + test "replays events into client_state and sets version to last index" do + state = + TestSupport.state_from_events(Account, [ + %Events.Opened{id: "a1", owner_id: "u1"}, + %Events.Deposited{id: "a1", amount: 100} + ]) + + assert %Aggregate.State{ + version: 1, + client_state: %State{id: "a1", owner_id: "u1", balance: 100} + } = state + end + + test "honors an explicit :version" do + state = + TestSupport.state_from_events(Account, [%Events.Opened{id: "a1", owner_id: "u1"}], + version: 7 + ) + + assert state.version == 7 + end + end + + describe "apply_events/4 (the then step)" do + test "folds new events onto an existing state and bumps the version" do + given = TestSupport.state_from_events(Account, [%Events.Opened{id: "a1", owner_id: "u1"}]) + + applied = + TestSupport.apply_events(Account, given, [%Events.Deposited{id: "a1", amount: 40}]) + + assert applied.client_state.balance == 40 + assert applied.version == given.version + 1 + end + end +end diff --git a/test/support/account/aggregate.ex b/test/support/account/aggregate.ex new file mode 100644 index 0000000..936da80 --- /dev/null +++ b/test/support/account/aggregate.ex @@ -0,0 +1,75 @@ +defmodule X3m.System.Test.Account.Aggregate do + @moduledoc false + use X3m.System.Aggregate + alias X3m.System.Message, as: SysMsg + alias X3m.System.Test.Account.{State, Commands, Events} + + @impl X3m.System.Aggregate + def initial_state, do: %State{} + + handle_msg :open_account, &Commands.Open.new/2, fn %SysMsg{request: cmd} = msg, + %State{} = state -> + event = %Events.Opened{id: cmd.id, owner_id: cmd.owner_id} + {:block, msg |> SysMsg.add_event(event) |> SysMsg.created(cmd.id), state} + end + + handle_msg :deposit, &Commands.Deposit.new/2, fn %SysMsg{request: cmd} = msg, + %State{} = state -> + event = %Events.Deposited{id: cmd.id, amount: cmd.amount} + {:block, msg |> SysMsg.add_event(event) |> SysMsg.ok(), state} + end + + handle_msg :withdraw, &Commands.Withdraw.new/2, fn %SysMsg{request: cmd} = msg, + %State{balance: balance} = state -> + if cmd.amount <= balance do + event = %Events.Withdrawn{id: cmd.id, amount: cmd.amount} + {:block, msg |> SysMsg.add_event(event) |> SysMsg.ok(), state} + else + event = %Events.WithdrawalRejected{id: cmd.id, amount: cmd.amount, balance: balance} + {:block, msg |> SysMsg.add_event(event) |> SysMsg.error(:insufficient_funds), state} + end + end + + handle_msg :close_account, fn + %SysMsg{assigns: %{invoked_by: %{admin?: true}}} = msg, %State{} = state -> + _close(msg, state) + + %SysMsg{assigns: %{invoked_by: %{user_id: uid}}} = msg, %State{owner_id: uid} = state -> + _close(msg, state) + + %SysMsg{} = msg, %State{} = state -> + {:noblock, SysMsg.error(msg, :forbidden), state} + end + + def apply_event(%Events.Opened{} = e, %State{} = state), + do: %State{state | id: e.id, owner_id: e.owner_id} + + def apply_event(%Events.Deposited{} = e, %State{} = state), + do: %State{state | balance: state.balance + e.amount} + + def apply_event(%Events.Withdrawn{} = e, %State{} = state), + do: %State{state | balance: state.balance - e.amount} + + def apply_event(%Events.WithdrawalRejected{} = e, %State{} = state), + do: %State{ + state + | highest_rejected_withdrawal: max(state.highest_rejected_withdrawal, e.amount) + } + + def apply_event(%Events.Closed{}, %State{} = state), + do: %State{state | closed?: true} + + defp _close(%SysMsg{} = msg, %State{id: id, balance: balance} = state) do + events = + if balance > 0, + do: [%Events.Withdrawn{id: id, amount: balance}, %Events.Closed{id: id}], + else: [%Events.Closed{id: id}] + + msg = + events + |> Enum.reduce(msg, fn event, acc -> SysMsg.add_event(acc, event) end) + |> SysMsg.ok() + + {:block, msg, state} + end +end diff --git a/test/support/account/command_helpers.ex b/test/support/account/command_helpers.ex new file mode 100644 index 0000000..871b621 --- /dev/null +++ b/test/support/account/command_helpers.ex @@ -0,0 +1,13 @@ +defmodule X3m.System.Test.Account.CommandHelpers do + @moduledoc false + alias X3m.System.Message, as: SysMsg + + def put_request(%Ecto.Changeset{valid?: false} = changeset, %SysMsg{} = msg), + do: SysMsg.put_request(changeset, msg) + + def put_request(%Ecto.Changeset{valid?: true} = changeset, %SysMsg{} = msg) do + changeset + |> Ecto.Changeset.apply_changes() + |> SysMsg.put_request(msg) + end +end diff --git a/test/support/account/commands/deposit.ex b/test/support/account/commands/deposit.ex new file mode 100644 index 0000000..9bd8788 --- /dev/null +++ b/test/support/account/commands/deposit.ex @@ -0,0 +1,28 @@ +defmodule X3m.System.Test.Account.Commands.Deposit do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + alias X3m.System.Message, as: SysMsg + alias X3m.System.Test.Account.{CommandHelpers, State} + + @primary_key false + embedded_schema do + field :id, :string + field :amount, :integer + end + + def new(%SysMsg{} = msg, %State{} = state) do + %__MODULE__{} + |> cast(msg.raw_request, [:id, :amount]) + |> validate_required([:id, :amount]) + |> validate_number(:amount, greater_than: 0) + |> _reject_if_closed(state) + |> CommandHelpers.put_request(msg) + end + + defp _reject_if_closed(changeset, %State{closed?: true}), + do: add_error(changeset, :id, "account is closed") + + defp _reject_if_closed(changeset, %State{}), + do: changeset +end diff --git a/test/support/account/commands/open.ex b/test/support/account/commands/open.ex new file mode 100644 index 0000000..ba1d032 --- /dev/null +++ b/test/support/account/commands/open.ex @@ -0,0 +1,20 @@ +defmodule X3m.System.Test.Account.Commands.Open do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + alias X3m.System.Message, as: SysMsg + alias X3m.System.Test.Account.CommandHelpers + + @primary_key false + embedded_schema do + field :id, :string + field :owner_id, :string + end + + def new(%SysMsg{} = msg, _state) do + %__MODULE__{} + |> cast(msg.raw_request, [:id, :owner_id]) + |> validate_required([:id, :owner_id]) + |> CommandHelpers.put_request(msg) + end +end diff --git a/test/support/account/commands/withdraw.ex b/test/support/account/commands/withdraw.ex new file mode 100644 index 0000000..0d67335 --- /dev/null +++ b/test/support/account/commands/withdraw.ex @@ -0,0 +1,21 @@ +defmodule X3m.System.Test.Account.Commands.Withdraw do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + alias X3m.System.Message, as: SysMsg + alias X3m.System.Test.Account.CommandHelpers + + @primary_key false + embedded_schema do + field :id, :string + field :amount, :integer + end + + def new(%SysMsg{} = msg, _state) do + %__MODULE__{} + |> cast(msg.raw_request, [:id, :amount]) + |> validate_required([:id, :amount]) + |> validate_number(:amount, greater_than: 0) + |> CommandHelpers.put_request(msg) + end +end diff --git a/test/support/account/events.ex b/test/support/account/events.ex new file mode 100644 index 0000000..7cc7beb --- /dev/null +++ b/test/support/account/events.ex @@ -0,0 +1,8 @@ +defmodule X3m.System.Test.Account.Events do + @moduledoc false + defmodule Opened, do: defstruct([:id, :owner_id]) + defmodule Deposited, do: defstruct([:id, :amount]) + defmodule Withdrawn, do: defstruct([:id, :amount]) + defmodule WithdrawalRejected, do: defstruct([:id, :amount, :balance]) + defmodule Closed, do: defstruct([:id]) +end diff --git a/test/support/account/state.ex b/test/support/account/state.ex new file mode 100644 index 0000000..c625e34 --- /dev/null +++ b/test/support/account/state.ex @@ -0,0 +1,4 @@ +defmodule X3m.System.Test.Account.State do + @moduledoc false + defstruct id: nil, owner_id: nil, balance: 0, closed?: false, highest_rejected_withdrawal: 0 +end From f8182f2ea9cd1c61366ddc8972533822b8066cf4 Mon Sep 17 00:00:00 2001 From: Burmaja Milan Date: Thu, 11 Jun 2026 13:45:33 +0100 Subject: [PATCH 8/9] Fix AggregateRegistry.has_key? --- lib/aggregate_registry.ex | 15 +++++++++---- test/aggregate/registry_test.exs | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 test/aggregate/registry_test.exs diff --git a/lib/aggregate_registry.ex b/lib/aggregate_registry.ex index 4ad4271..ee32ae7 100644 --- a/lib/aggregate_registry.ex +++ b/lib/aggregate_registry.ex @@ -66,13 +66,20 @@ defmodule X3m.System.AggregateRegistry do defp ref_table(name), do: Module.concat(name, Refs) def handle_call({:has_key?, key}, _from, state) do - {:reply, Map.has_key?(state.processes, key), state} - end + result = + state.pid_table + |> _get(key) + |> case do + {:ok, _pid} -> true + :error -> false + end - def handle_call({:get, key}, _from, state) do - {:reply, Map.fetch(state.processes, key), state} + {:reply, result, state} end + def handle_call({:get, key}, _from, state), + do: {:reply, _get(state.pid_table, key), state} + def handle_call({:register, key, pid}, _from, state) do result = case _get(state.pid_table, key) do diff --git a/test/aggregate/registry_test.exs b/test/aggregate/registry_test.exs new file mode 100644 index 0000000..136d42d --- /dev/null +++ b/test/aggregate/registry_test.exs @@ -0,0 +1,38 @@ +defmodule X3m.System.AggregateRegistryTest do + use ExUnit.Case, async: false + + alias X3m.System.AggregateRegistry, as: Registry + + setup do + name = Module.concat(__MODULE__, :"R#{System.unique_integer([:positive])}") + start_supervised!(%{id: name, start: {Registry, :start_link, [name]}}) + %{registry: name} + end + + test "register then get returns the pid; has_key? is true", %{registry: reg} do + pid = spawn(fn -> Process.sleep(:infinity) end) + + assert :ok == Registry.register(reg, "k1", pid) + assert {:ok, ^pid} = Registry.get(reg, "k1") + assert Registry.has_key?(reg, "k1") == true + assert Registry.has_key?(reg, "missing") == false + end + + test "registering an already-registered key returns an error", %{registry: reg} do + pid = spawn(fn -> Process.sleep(:infinity) end) + + assert :ok == Registry.register(reg, "dup", pid) + assert {:error, :key_already_registered, ^pid} = Registry.register(reg, "dup", pid) + end + + test "entry is removed when the registered process goes down", %{registry: reg} do + pid = spawn(fn -> Process.sleep(:infinity) end) + + assert :ok == Registry.register(reg, "k2", pid) + Process.exit(pid, :kill) + Process.sleep(50) + + assert :error == Registry.get(reg, "k2") + assert Registry.has_key?(reg, "k2") == false + end +end From bea3c92240f7db76cdae491544297322467be3ea Mon Sep 17 00:00:00 2001 From: Burmaja Milan Date: Thu, 11 Jun 2026 19:06:35 +0100 Subject: [PATCH 9/9] Add optional set_state to Aggregate --- .dialyzer_ignore.exs | 6 +- CHANGELOG.md | 5 + coveralls.json | 2 +- guides/aggregates-and-event-sourcing.md | 123 +++- lib/aggregate.ex | 16 + lib/gen_aggregate.ex | 11 + lib/gen_aggregate_mod.ex | 1 + lib/message_handler.ex | 63 +-- lib/message_handler/unload.ex | 72 +++ test/account_aggregate_test.exs | 22 +- test/aggregate/runtime_test.exs | 533 ++++++++++++++++++ test/support/account/aggregate.ex | 26 + test/support/account/event_store.ex | 100 ++++ test/support/account/events.ex | 1 + test/support/account/local_aggregates.ex | 4 + test/support/account/message_handler.ex | 15 + .../account/reactive_message_handler.ex | 26 + .../account/snapshot_message_handler.ex | 44 ++ test/support/account/state_store.ex | 31 + .../account/timeout_message_handler.ex | 12 + .../account/unload_on_events_handler.ex | 13 + .../unload_on_partial_state_handler.ex | 18 + .../account/unload_on_state_handler.ex | 20 + test/test_helper.exs | 9 + 24 files changed, 1118 insertions(+), 55 deletions(-) create mode 100644 lib/message_handler/unload.ex create mode 100644 test/aggregate/runtime_test.exs create mode 100644 test/support/account/event_store.ex create mode 100644 test/support/account/local_aggregates.ex create mode 100644 test/support/account/message_handler.ex create mode 100644 test/support/account/reactive_message_handler.ex create mode 100644 test/support/account/snapshot_message_handler.ex create mode 100644 test/support/account/state_store.ex create mode 100644 test/support/account/timeout_message_handler.ex create mode 100644 test/support/account/unload_on_events_handler.ex create mode 100644 test/support/account/unload_on_partial_state_handler.ex create mode 100644 test/support/account/unload_on_state_handler.ex diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index b6b489d..fe51488 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -1,5 +1 @@ -[ - {"test/support/test_scheduler.ex", :pattern_match, 0}, - {"test/support/test_scheduler.ex", :pattern_match, 2}, - {"test/support/router.ex", :pattern_match, 16} -] +[] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6715f14..4bd0135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 0.9.1 + +- Add set_state optional callback to Aggregate +- Add documentation. + # 0.9.0 - Breaking change in telemetry events from dispatcher and router diff --git a/coveralls.json b/coveralls.json index 5458c91..f01db40 100644 --- a/coveralls.json +++ b/coveralls.json @@ -1,6 +1,6 @@ { "coverage_options": { - "minimum_coverage": 47.0 + "minimum_coverage": 83.0 }, "skip_files": [ "test/support" diff --git a/guides/aggregates-and-event-sourcing.md b/guides/aggregates-and-event-sourcing.md index d029e32..2128db0 100644 --- a/guides/aggregates-and-event-sourcing.md +++ b/guides/aggregates-and-event-sourcing.md @@ -328,6 +328,118 @@ A few things to note: - No message handler, repo, or running process is involved, so these are plain, fast, `async: true` unit tests. +## Testing the full lifecycle + +The tests above cover your command logic. To exercise the *runtime* — loading and spawning +the aggregate process, persisting events, idempotency, and your handler's **overrides** +(`save_events/1`, `save_state/3`, `when_pid_is_not_registered/3`, `:unload_aggregate_on`) — +you need a running aggregate and a repo. In tests, back it with an **in-memory +`Aggregate.Repo`** instead of a real event store: + +```elixir +defmodule MyApp.Accounts.InMemoryEventStore do + @moduledoc false + use X3m.System.Aggregate.Repo + use Agent + + alias X3m.System.Message + + def start_link(_opts \\ []), + do: Agent.start_link(fn -> %{} end, name: __MODULE__) + + def reset(), + do: Agent.update(__MODULE__, fn _state -> %{} end) + + @impl true + def has?(stream_name), + do: Agent.get(__MODULE__, &Map.has_key?(&1, stream_name)) + + @impl true + def stream_events(stream_name, _start_at \\ 0, _per_page \\ 1_000), + do: Agent.get(__MODULE__, &Map.get(&1, stream_name, [])) + + @impl true + def delete_stream(stream_name, _hard_delete?, _expected_version) do + Agent.update(__MODULE__, &Map.delete(&1, stream_name)) + :ok + end + + @impl true + def save_events(stream_name, %Message{} = message, metadata) do + __MODULE__ + |> Agent.get_and_update(fn streams -> + existing = Map.get(streams, stream_name, []) + current = length(existing) - 1 + + # optimistic concurrency: the expected version is the aggregate's loaded version + # (-1 for a new aggregate), so a "create" against an existing stream is rejected. + if message.aggregate_meta.version != current do + {{:error, :wrong_expected_version, current}, streams} + else + # stamp message.id into metadata so idempotency survives a rehydrate + meta = Map.put(metadata, :message_id, message.id) + + appended = + message.events + |> Enum.with_index(current + 1) + |> Enum.map(fn {event, number} -> {event, number, meta} end) + + last = current + length(message.events) + {{:ok, last}, Map.put(streams, stream_name, existing ++ appended)} + end + end) + end +end +``` + +Start it alongside the aggregate's supervision tree in `test/test_helper.exs`: + +```elixir +{:ok, _} = MyApp.Accounts.InMemoryEventStore.start_link() + +{:ok, _} = + X3m.System.LocalAggregatesSupervision.start_link([MyApp.LocalAggregates, MyApp]) +``` + +Point a test message handler at the in-memory store, then drive it by calling the generated +functions directly and asserting on both the reply and the stored events: + +```elixir +defmodule MyApp.Accounts.MessageHandlerTest do + # the pid facade and registry are named per aggregate module + use ExUnit.Case, async: false + + import X3m.System.Aggregate.TestSupport + alias MyApp.Accounts.{MessageHandler, InMemoryEventStore, Events} + + setup do + InMemoryEventStore.reset() + :ok + end + + test "opens an account and persists the event" do + id = UUID.uuid4() + msg = command_message(:open_account, %{"id" => id, "owner" => "Ada"}) + + assert {:reply, replied} = MessageHandler.open_account(msg) + assert {:created, ^id, 0} == replied.response + assert [{%Events.Opened{}, 0, _}] = InMemoryEventStore.stream_events("accounts-" <> id) + end +end +``` + +Notes: + +- Use a fresh aggregate id per test; reuse an id only when you are deliberately testing + rehydration or idempotency (unload the process — `AggregatePidFacade.exit_process/3` — to + force the next command to reload). +- `save_events/3` stamps `message.id` into each event's metadata so idempotency survives a + rehydrate (the aggregate re-reads it via `processed_message_id/1` while replaying). +- This is how you test *your* overrides — `save_events/1` reacting to or filtering events, + `:unload_aggregate_on` rules, the save/load callbacks below — rather than the library + internals. For a non-event-sourced handler, point `save_state/3` and + `when_pid_is_not_registered/3` at a plain in-memory store the same way. + ## Validating without persisting (`dry_run`) `X3m.System.Dispatcher.validate/1` dispatches the command with `dry_run: true`: the @@ -354,9 +466,14 @@ Together they support a spectrum: - **Snapshots on top of events:** `save_state/3` stores a periodic snapshot; `when_pid_is_not_registered/3` loads the latest snapshot and replays only the events after it. -- **Non-event-sourced (state-based) aggregates:** `save_state/3` persists the full - current state and `when_pid_is_not_registered/3` loads it directly — no event replay - at all. +- **Non-event-sourced (state-based) aggregates:** `save_state/3` persists the full current + state (it receives the wrapped `%X3m.System.Aggregate.State{}`, so you can store its + `client_state` and `version`); `when_pid_is_not_registered/3` loads that row, spawns the + process and seeds it with `X3m.System.GenAggregate.set_state(pid, loaded_state, version)` — + no event replay at all. `set_state/3` rebuilds the process's `client_state` through your + aggregate's `set_state/1` callback (the state-based analogue of `initial_state/0` — "for + this loaded data, give me back your state"; defaults to the identity) and restores the + version. Your command logic (`handle_msg`) is identical in all three cases; only how state is saved and restored differs. diff --git a/lib/aggregate.ex b/lib/aggregate.ex index e5d223d..c7eb02c 100644 --- a/lib/aggregate.ex +++ b/lib/aggregate.ex @@ -70,6 +70,18 @@ defmodule X3m.System.Aggregate do """ @callback initial_state :: map() + @doc """ + Rebuilds the aggregate's client state from `loaded_state` — the state-based + (non-event-sourced) analogue of `c:initial_state/0`: "for this loaded data, give me back + your state". + + Called by `X3m.System.GenAggregate.set_state/2` when a message handler hydrates a process + from saved state instead of replaying events (see the `when_pid_is_not_registered/3` override + in `X3m.System.MessageHandler`). Defaults to the identity — `loaded_state` is taken as the + client state — so override it only when the persisted shape differs from your client state. + """ + @callback set_state(loaded_state :: term()) :: client_state :: map() + @doc """ Builds the initial wrapped `State` for `aggregate_mod`, seeding `client_state` from its `c:initial_state/0`. @@ -189,6 +201,10 @@ defmodule X3m.System.Aggregate do do: %AggregateState{state | version: last_version} def processed_message_id(nil), do: nil + + def set_state(loaded_state), do: loaded_state + defoverridable set_state: 1 + @before_compile X3m.System.Aggregate end end diff --git a/lib/gen_aggregate.ex b/lib/gen_aggregate.ex index 6fae6ef..8e4ec55 100644 --- a/lib/gen_aggregate.ex +++ b/lib/gen_aggregate.ex @@ -44,6 +44,11 @@ defmodule X3m.System.GenAggregate do end end + @impl X3m.System.GenAggregateMod + @spec set_state(pid, loaded_state :: term(), version :: integer()) :: :ok + def set_state(pid, loaded_state, version), + do: GenServer.call(pid, {:set_state, loaded_state, version}) + # Server side @impl GenServer @@ -68,6 +73,12 @@ defmodule X3m.System.GenAggregate do {:reply, :ok, %State{state | aggregate_state: aggregate_state}} end + def handle_call({:set_state, loaded_state, version}, _from, %State{} = state) do + client_state = apply(state.aggregate_mod, :set_state, [loaded_state]) + aggregate_state = %{state.aggregate_state | client_state: client_state, version: version} + {:reply, :ok, %State{state | aggregate_state: aggregate_state}} + end + def handle_call( {:handle_msg, cmd, %Message{} = message, commit_timeout}, _from, diff --git a/lib/gen_aggregate_mod.ex b/lib/gen_aggregate_mod.ex index a9175f8..981239e 100644 --- a/lib/gen_aggregate_mod.ex +++ b/lib/gen_aggregate_mod.ex @@ -8,4 +8,5 @@ defmodule X3m.System.GenAggregateMod do {:ok, X3m.System.Message.t(), any} | any @callback commit(pid, String.t(), X3m.System.Message.t(), integer) :: {:ok, X3m.System.Aggregate.State.t()} | :transaction_timeout + @callback set_state(pid, loaded_state :: term(), version :: integer()) :: :ok end diff --git a/lib/message_handler.ex b/lib/message_handler.ex index d3d68b7..8af5b60 100644 --- a/lib/message_handler.ex +++ b/lib/message_handler.ex @@ -179,7 +179,8 @@ defmodule X3m.System.MessageHandler do with {:ok, pid} <- @pid_facade_mod.spawn_new(@pid_facade_name, id), {:block, %X3m.System.Message{} = message, transaction_id} <- @gen_aggregate_mod.handle_msg(pid, cmd, message, opts), - {:ok, message, version} <- _apply_changes(pid, transaction_id, message) do + {:ok, %X3m.System.Message{} = message, version} <- + _apply_changes(pid, transaction_id, message) do case message.response do {:created, ^id} -> %X3m.System.Message{message | response: {:created, id, version}} other -> message @@ -213,7 +214,8 @@ defmodule X3m.System.MessageHandler do @pid_facade_mod.get_pid(@pid_facade_name, id, &when_pid_is_not_registered/3), {:block, %X3m.System.Message{} = message, transaction_id} <- @gen_aggregate_mod.handle_msg(pid, cmd, message, opts), - {:ok, message, version} <- _apply_changes(pid, transaction_id, message) do + {:ok, %X3m.System.Message{} = message, version} <- + _apply_changes(pid, transaction_id, message) do case message.response do :ok -> %X3m.System.Message{message | response: {:ok, version}} {:ok, any} -> %X3m.System.Message{message | response: {:ok, any, version}} @@ -309,49 +311,13 @@ defmodule X3m.System.MessageHandler do end end - @spec _schedule_process_teardown(pid(), X3m.System.Message.t(), any()) :: - :skip | :unload | {:in, pos_integer()} - defp _schedule_process_teardown(pid, %X3m.System.Message{} = message, state) do - @unload_aggregate_on - |> Map.get(:events, %{}) - |> Enum.each(fn {event, time} -> - _schedule_process_tear_down_on_events(message.events, event, time, pid) - end) - - _schedule_process_teardown_on_state(Map.get(@unload_aggregate_on, :state), state) - end - - defp _schedule_process_teardown_on_state(fun, state) when is_function(fun) do - try do - fun.(state) - rescue - FunctionClauseError -> - :skip - end - end - - defp _schedule_process_teardown_on_state(_, _), - do: :skip - - defp _schedule_process_tear_down_on_events([], _, _, _), - do: :ok - - defp _schedule_process_tear_down_on_events([%{__struct__: event} | rest], event, time, pid) do - _create_tear_down_scheduler(pid, time, event) - _schedule_process_tear_down_on_events(rest, event, time, pid) - end - - defp _schedule_process_tear_down_on_events([_ | rest], event, time, pid) do - _schedule_process_tear_down_on_events(rest, event, time, pid) - end - - defp _create_tear_down_scheduler(pid, {:in, milliseconds}, event) do - reason = "Scheduled tear down of aggregate on #{inspect(event)}" - Process.send_after(@pid_facade_name, {:exit_process, pid, reason}, milliseconds) - end - defp _apply_changes(pid, transaction_id, %X3m.System.Message{dry_run: false} = message) do - case save_events(message) do + # `save_events/1` is overridable; dispatch via apply/3 so a consumer impl that can never + # error (e.g. a non-event-sourced handler) does not make the `error ->` clause below a + # "clause cannot match" warning under --warnings-as-errors. + __MODULE__ + |> apply(:save_events, [message]) + |> case do {:ok, last_event_number} -> {:ok, new_state} = @gen_aggregate_mod.commit(pid, transaction_id, message, last_event_number) @@ -362,7 +328,14 @@ defmodule X3m.System.MessageHandler do save_state(message.aggregate_meta.id, new_state, message) - case _schedule_process_teardown(pid, message, new_state.client_state) do + @unload_aggregate_on + |> X3m.System.MessageHandler.Unload.decide( + message, + new_state.client_state, + pid, + @pid_facade_name + ) + |> case do :unload -> reason = "Unloading aggregate because of it's state" exit_process(message.aggregate_meta.id, {:normal, reason}) diff --git a/lib/message_handler/unload.ex b/lib/message_handler/unload.ex new file mode 100644 index 0000000..bfecb77 --- /dev/null +++ b/lib/message_handler/unload.ex @@ -0,0 +1,72 @@ +defmodule X3m.System.MessageHandler.Unload do + @moduledoc !""" + Internal. Decides whether and when an aggregate process is torn down after a + command, from a message handler's `:unload_aggregate_on` rules (keyed on emitted + events and/or current state). + + Extracted from the `X3m.System.MessageHandler` macro on purpose: here the rules + are an ordinary `map()` argument, so the compiler does not narrow them to the + empty-map literal a consumer without `:unload_aggregate_on` would otherwise inline + (which trips the type checker on the optional `:events`/`:state` lookups). + """ + + alias X3m.System.Message + + @doc """ + Applies the `unload_aggregate_on` `rules` for a just-committed `message`. + + `rules` is the handler's `:unload_aggregate_on` map, with optional keys: + + * `:events` - `%{event_struct_module => {:in, milliseconds}}`; for each emitted event whose + struct matches a key, a delayed `{:exit_process, pid, reason}` is sent to `pid_facade_name`. + * `:state` - a 1-arity function of the new `client_state` returning `:skip`, `:unload` or + `{:in, milliseconds}`. + + Schedules the event-based teardowns as a side effect and returns the state-based decision for + the caller to act on (`:skip` keeps the process, `:unload` tears it down now, `{:in, ms}` + after `ms`). A `FunctionClauseError` raised by the `:state` function is treated as `:skip`. + """ + @spec decide( + rules :: map(), + message :: Message.t(), + client_state :: term(), + pid :: pid(), + pid_facade_name :: atom() + ) :: :skip | :unload | {:in, pos_integer()} + def decide(rules, %Message{} = message, client_state, pid, pid_facade_name) do + rules + |> Map.get(:events, %{}) + |> Enum.each(fn {event, time} -> + _schedule_on_events(message.events, event, time, pid, pid_facade_name) + end) + + rules + |> Map.get(:state) + |> _decide_on_state(client_state) + end + + defp _decide_on_state(fun, client_state) when is_function(fun) do + fun.(client_state) + rescue + FunctionClauseError -> :skip + end + + defp _decide_on_state(_state_rule, _client_state), + do: :skip + + defp _schedule_on_events([], _event, _time, _pid, _pid_facade_name), + do: :ok + + defp _schedule_on_events([%{__struct__: event} | rest], event, time, pid, pid_facade_name) do + _create_scheduler(pid, time, event, pid_facade_name) + _schedule_on_events(rest, event, time, pid, pid_facade_name) + end + + defp _schedule_on_events([_other | rest], event, time, pid, pid_facade_name), + do: _schedule_on_events(rest, event, time, pid, pid_facade_name) + + defp _create_scheduler(pid, {:in, milliseconds}, event, pid_facade_name) do + reason = "Scheduled tear down of aggregate on #{inspect(event)}" + Process.send_after(pid_facade_name, {:exit_process, pid, reason}, milliseconds) + end +end diff --git a/test/account_aggregate_test.exs b/test/account_aggregate_test.exs index 14c5eb9..6ee90db 100644 --- a/test/account_aggregate_test.exs +++ b/test/account_aggregate_test.exs @@ -179,7 +179,9 @@ defmodule X3m.System.Test.AccountAggregateTest do describe "idempotency" do test "a message whose id was already processed returns :ok, no events, and warns" do - state = TestSupport.state_from_events(Account, [%Events.Opened{id: "a1", owner_id: "u1"}]) + %Aggregate.State{} = + state = TestSupport.state_from_events(Account, [%Events.Opened{id: "a1", owner_id: "u1"}]) + msg = TestSupport.command_message(:deposit, %{"id" => "a1", "amount" => 50}) processed = %Aggregate.State{ @@ -193,4 +195,22 @@ defmodule X3m.System.Test.AccountAggregateTest do assert log =~ "already processed" end end + + describe "set_state/1 (non-ES hydration callback)" do + test "defaults to returning the loaded state as the client state" do + loaded = %State{id: "a9", owner_id: "u9", balance: 500} + assert loaded == Account.set_state(loaded) + end + end + + describe "processed_message_id/1" do + test "reads the message id from event metadata" do + assert Account.processed_message_id(%{message_id: "m1"}) == "m1" + end + + test "returns nil when metadata carries no message id" do + assert Account.processed_message_id(%{}) == nil + assert Account.processed_message_id(nil) == nil + end + end end diff --git a/test/aggregate/runtime_test.exs b/test/aggregate/runtime_test.exs new file mode 100644 index 0000000..6d1a594 --- /dev/null +++ b/test/aggregate/runtime_test.exs @@ -0,0 +1,533 @@ +defmodule X3m.System.Aggregate.RuntimeTest do + use ExUnit.Case, async: false + + alias X3m.System.Aggregate.TestSupport + alias X3m.System.Test.Account.{EventStore, MessageHandler, ReactiveMessageHandler} + + alias X3m.System.Test.Account.{ + UnloadOnEventsHandler, + UnloadOnStateHandler, + UnloadOnPartialStateHandler + } + + alias X3m.System.Test.Account.TimeoutMessageHandler + alias X3m.System.Test.Account.{SnapshotMessageHandler, StateStore} + + alias X3m.System.Test.Account.Events + + setup do + EventStore.reset() + :ok + end + + describe "event-sourced lifecycle" do + test "on_new_aggregate spawns, persists events, and replies {:created, id, version}" do + account_id = _id() + + msg = + TestSupport.command_message(:open_account, %{"id" => account_id, "owner_id" => "u1"}) + + assert {:reply, replied} = MessageHandler.open_account(msg) + assert {:created, account_id, 0} == replied.response + + assert [{%Events.Opened{owner_id: "u1"}, 0, %{message_id: _}}] = + EventStore.dump("accounts-" <> account_id) + end + + test "_unload/1 terminates the live process and clears it from the registry" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.open_account() + + assert {:ok, pid} = _registry_get(account_id) + assert Process.alive?(pid) + + :ok = _unload(account_id) + + assert :error == _registry_get(account_id) + refute Process.alive?(pid) + end + + test "on_aggregate on a cold (persisted) id rehydrates from the stream and runs the command" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.open_account() + + :ok = _unload(account_id) + + {:reply, replied} = + :deposit + |> TestSupport.command_message(%{"id" => account_id, "amount" => 100}) + |> MessageHandler.deposit() + + assert {:ok, 1} = replied.response + + assert [_opened, {%Events.Deposited{amount: 100}, 1, _}] = + EventStore.dump("accounts-" <> account_id) + end + + test "on_new_aggregate fails when the aggregate is already hot in memory" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.open_account() + + {:reply, replied} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.open_account() + + assert {:error, :key_already_registered, _pid} = replied.response + end + + test "on_new_aggregate fails when the aggregate already exists in persistence" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.open_account() + + :ok = _unload(account_id) + + {:reply, replied} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.open_account() + + assert {:error, :wrong_expected_version, 0} = replied.response + Process.sleep(50) + assert :error == _registry_get(account_id) + end + + test "on_aggregate fails when the aggregate does not exist" do + account_id = _id() + + {:reply, replied} = + :deposit + |> TestSupport.command_message(%{"id" => account_id, "amount" => 10}) + |> MessageHandler.deposit() + + assert {:error, :not_found} = replied.response + end + + test "on_maybe_new_aggregate creates when absent, updates owner when changed, no-ops when unchanged" do + account_id = _id() + stream = "accounts-" <> account_id + + # absent -> creates the aggregate and sets the owner + {:reply, created} = + :ensure_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.ensure_account() + + assert {:created, ^account_id, 0} = created.response + assert [{%Events.Opened{owner_id: "u1"}, 0, _}] = EventStore.dump(stream) + + # present, same owner -> no-op (no new event, version unchanged) + {:reply, unchanged} = + :ensure_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.ensure_account() + + assert {:ok, 0} = unchanged.response + assert 1 == length(EventStore.dump(stream)) + + # present, different owner -> works on the existing aggregate (OwnerChanged) + {:reply, changed} = + :ensure_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u2"}) + |> MessageHandler.ensure_account() + + assert {:ok, 1} = changed.response + + assert [ + {%Events.Opened{owner_id: "u1"}, 0, _}, + {%Events.OwnerChanged{owner_id: "u2"}, 1, _} + ] = + EventStore.dump(stream) + end + + test "the same message handled twice on a hot aggregate is deduped (no second event)" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.open_account() + + deposit = TestSupport.command_message(:deposit, %{"id" => account_id, "amount" => 50}) + + {:reply, first} = MessageHandler.deposit(deposit) + {:reply, second} = MessageHandler.deposit(deposit) + + assert {:ok, _} = first.response + assert {:ok, _} = second.response + + assert 1 == length(_deposits("accounts-" <> account_id)) + end + + test "idempotency survives a rehydrate (id read from event metadata on replay)" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.open_account() + + deposit = TestSupport.command_message(:deposit, %{"id" => account_id, "amount" => 50}) + {:reply, _} = MessageHandler.deposit(deposit) + + :ok = _unload(account_id) + + {:reply, replayed} = MessageHandler.deposit(deposit) + assert {:ok, _} = replayed.response + + assert 1 == length(_deposits("accounts-" <> account_id)) + end + + test "dry_run: true commits nothing and rolls back" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.open_account() + + before = EventStore.dump("accounts-" <> account_id) + + msg = + :deposit + |> X3m.System.Message.new( + raw_request: %{"id" => account_id, "amount" => 100}, + dry_run: true + ) + + {:reply, replied} = MessageHandler.deposit(msg) + + # on_aggregate dry_run echoes {:ok, dry_run_version, version} (both the current version) + assert {:ok, 0, 0} == replied.response + assert before == EventStore.dump("accounts-" <> account_id) + end + + test "dry_run: :verbose returns the events without persisting them" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.open_account() + + before = EventStore.dump("accounts-" <> account_id) + + msg = + :deposit + |> X3m.System.Message.new( + raw_request: %{"id" => account_id, "amount" => 100}, + dry_run: :verbose + ) + + {:reply, replied} = MessageHandler.deposit(msg) + + assert {:ok, [%Events.Deposited{amount: 100}], _version} = replied.response + assert before == EventStore.dump("accounts-" <> account_id) + end + end + + describe "save_events/1 override" do + test "reacts to produced events and filters which ones persist" do + # registered name auto-clears when this (async: false) test process exits + Process.register(self(), :reactive_test_observer) + + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> ReactiveMessageHandler.open_account() + + # withdraw more than the (zero) balance -> aggregate emits WithdrawalRejected with an error + {:reply, replied} = + :withdraw + |> TestSupport.command_message(%{"id" => account_id, "amount" => 999}) + |> ReactiveMessageHandler.withdraw() + + assert {:error, :insufficient_funds} == replied.response + + # the override reacted with the emitted event... + assert_received {:reacted, [%Events.WithdrawalRejected{amount: 999}]} + + # ...but filtered it out of persistence (only the Opened event remains) + rejected = + ("reactive-accounts-" <> account_id) + |> EventStore.dump() + |> Enum.filter(fn {event, _number, _meta} -> + match?(%Events.WithdrawalRejected{}, event) + end) + + assert [] == rejected + end + end + + describe "save failures" do + test "a generic save_events error kills the aggregate and returns the error" do + account_id = _id() + EventStore.fail("accounts-" <> account_id, {:error, :boom}) + + {:reply, replied} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> MessageHandler.open_account() + + assert {:error, :boom} == replied.response + + Process.sleep(50) + assert :error == _registry_get(account_id) + end + end + + describe "unload_aggregate_on" do + test "events rule schedules a delayed teardown" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> UnloadOnEventsHandler.open_account() + + {:reply, _} = + :deposit + |> TestSupport.command_message(%{"id" => account_id, "amount" => 10}) + |> UnloadOnEventsHandler.deposit() + + assert {:ok, _pid} = _registry_get(account_id) + Process.sleep(80) + assert :error == _registry_get(account_id) + end + + test "state rule :unload tears the process down on close" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> UnloadOnStateHandler.open_account() + + {:reply, _} = + :close_account + |> TestSupport.command_message(%{"id" => account_id}, %{invoked_by: %{admin?: true}}) + |> UnloadOnStateHandler.close_account() + + Process.sleep(50) + assert :error == _registry_get(account_id) + end + + test "state rule {:in, ms} delays teardown; :skip keeps the process" do + big = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => big, "owner_id" => "u1"}) + |> UnloadOnStateHandler.open_account() + + {:reply, _} = + :deposit + |> TestSupport.command_message(%{"id" => big, "amount" => 5_000}) + |> UnloadOnStateHandler.deposit() + + assert {:ok, _pid} = _registry_get(big) + Process.sleep(80) + assert :error == _registry_get(big) + + small = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => small, "owner_id" => "u1"}) + |> UnloadOnStateHandler.open_account() + + {:reply, _} = + :deposit + |> TestSupport.command_message(%{"id" => small, "amount" => 10}) + |> UnloadOnStateHandler.deposit() + + Process.sleep(80) + assert {:ok, _pid} = _registry_get(small) + end + + test "a FunctionClauseError in the state fn is rescued to :skip" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> UnloadOnPartialStateHandler.open_account() + + {:reply, _} = + :deposit + |> TestSupport.command_message(%{"id" => account_id, "amount" => 10}) + |> UnloadOnPartialStateHandler.deposit() + + Process.sleep(50) + assert {:ok, _pid} = _registry_get(account_id) + end + end + + describe "commit timeout" do + test "offloads the aggregate, persists the events, and a retry rehydrates correct state" do + account_id = _id() + stream = "timeout-accounts-" <> account_id + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> TimeoutMessageHandler.open_account() + + # make save_events slower than the 50ms commit_timeout + EventStore.delay(stream, 200) + + deposit = TestSupport.command_message(:deposit, %{"id" => account_id, "amount" => 100}) + + # Documented contract: on a commit timeout the handler's hard `{:ok, _} = commit(...)` + # match fails (commit/4 returns :transaction_timeout), so the caller crashes -> the client + # sees a timeout (no reply). This is intended; recovery is via offload + retry below. + # (A future, configurable router-level retry on such timeouts would be safe because + # idempotency reconciles the already-persisted events.) + crashed = + try do + TimeoutMessageHandler.deposit(deposit) + false + rescue + MatchError -> true + catch + :exit, _ -> true + end + + Process.sleep(250) + + # 1) events were actually persisted despite the timeout + assert [_opened, {%Events.Deposited{amount: 100}, 1, _}] = EventStore.dump(stream) + # 2) the aggregate was offloaded (cleared from the registry) + assert :error == _registry_get(account_id) + + # 3) a retry rehydrates from the persisted events (not stale memory) and succeeds + EventStore.delay(stream, 0) + + {:reply, retried} = + :deposit + |> TestSupport.command_message(%{"id" => account_id, "amount" => 5}) + |> TimeoutMessageHandler.deposit() + + assert {:ok, _version} = retried.response + # the timed-out-but-persisted 100, plus the retried 5 + assert 2 == length(_deposits(stream)) + # the timed-out command crashed the caller (client-visible timeout) — intended behavior + assert true == crashed + end + end + + describe "non-event-sourced (standard save/load) lifecycle" do + setup do + StateStore.reset() + :ok + end + + test "save_state persists the full state row (client_state + version) on commit" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> SnapshotMessageHandler.open_account() + + {:reply, _} = + :deposit + |> TestSupport.command_message(%{"id" => account_id, "amount" => 250}) + |> SnapshotMessageHandler.deposit() + + assert {:ok, {state, version}} = StateStore.get(account_id) + assert 250 == state.balance + assert "u1" == state.owner_id + assert 1 == version + end + + test "a cold command loads state via when_pid_is_not_registered and runs against it" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> SnapshotMessageHandler.open_account() + + {:reply, _} = + :deposit + |> TestSupport.command_message(%{"id" => account_id, "amount" => 250}) + |> SnapshotMessageHandler.deposit() + + # drop the live process; only the StateStore row remains + :ok = _unload(account_id) + + {:reply, replied} = + :deposit + |> TestSupport.command_message(%{"id" => account_id, "amount" => 100}) + |> SnapshotMessageHandler.deposit() + + assert {:ok, _version} = replied.response + assert {:ok, {state, _version}} = StateStore.get(account_id) + assert 350 == state.balance + end + + test "dry_run leaves the state store unchanged (same as the event-sourced store)" do + account_id = _id() + + {:reply, _} = + :open_account + |> TestSupport.command_message(%{"id" => account_id, "owner_id" => "u1"}) + |> SnapshotMessageHandler.open_account() + + before = StateStore.get(account_id) + + msg = + :deposit + |> X3m.System.Message.new( + raw_request: %{"id" => account_id, "amount" => 100}, + dry_run: true + ) + + {:reply, _replied} = SnapshotMessageHandler.deposit(msg) + + assert before == StateStore.get(account_id) + end + end + + defp _deposits(stream_name) do + stream_name + |> EventStore.dump() + |> Enum.filter(fn {event, _number, _meta} -> match?(%Events.Deposited{}, event) end) + end + + defp _unload(account_id) do + X3m.System.Test.Account.Aggregate + |> X3m.System.AggregatePidFacade.name() + |> X3m.System.AggregatePidFacade.exit_process(account_id, :unload_for_test) + + Process.sleep(50) + :ok + end + + defp _registry_get(account_id) do + X3m.System.Test.Account.Aggregate + |> X3m.System.AggregateRegistry.name() + |> X3m.System.AggregateRegistry.get(account_id) + end + + defp _id(), do: UUID.uuid4() +end diff --git a/test/support/account/aggregate.ex b/test/support/account/aggregate.ex index 936da80..ab727f9 100644 --- a/test/support/account/aggregate.ex +++ b/test/support/account/aggregate.ex @@ -30,6 +30,27 @@ defmodule X3m.System.Test.Account.Aggregate do end end + handle_msg :ensure_account, &Commands.Open.new/2, fn + %SysMsg{request: cmd} = msg, %State{id: nil} = state -> + event = %Events.Opened{id: cmd.id, owner_id: cmd.owner_id} + + msg = + msg + |> SysMsg.add_event(event) + |> SysMsg.created(cmd.id) + + {:block, msg, state} + + %SysMsg{request: %Commands.Open{owner_id: owner_id}} = msg, + %State{owner_id: owner_id} = state -> + # owner unchanged -> no-op (no event) + {:noblock, SysMsg.ok(msg), state} + + %SysMsg{request: cmd} = msg, %State{} = state -> + event = %Events.OwnerChanged{id: cmd.id, owner_id: cmd.owner_id} + {:block, msg |> SysMsg.add_event(event) |> SysMsg.ok(), state} + end + handle_msg :close_account, fn %SysMsg{assigns: %{invoked_by: %{admin?: true}}} = msg, %State{} = state -> _close(msg, state) @@ -41,9 +62,14 @@ defmodule X3m.System.Test.Account.Aggregate do {:noblock, SysMsg.error(msg, :forbidden), state} end + def processed_message_id(%{message_id: id}), do: id + def apply_event(%Events.Opened{} = e, %State{} = state), do: %State{state | id: e.id, owner_id: e.owner_id} + def apply_event(%Events.OwnerChanged{} = e, %State{} = state), + do: %State{state | owner_id: e.owner_id} + def apply_event(%Events.Deposited{} = e, %State{} = state), do: %State{state | balance: state.balance + e.amount} diff --git a/test/support/account/event_store.ex b/test/support/account/event_store.ex new file mode 100644 index 0000000..52df0f9 --- /dev/null +++ b/test/support/account/event_store.ex @@ -0,0 +1,100 @@ +defmodule X3m.System.Test.Account.EventStore do + @moduledoc false + # In-memory event store implementing X3m.System.Aggregate.Repo for tests. + # Stores %{stream_name => [{event, event_number, metadata}]} (events in order), + # stamps message.id into each event's metadata (for replay-idempotency), enforces + # optimistic concurrency on save (expected = message.aggregate_meta.version), and + # supports per-stream fault/delay injection. + use X3m.System.Aggregate.Repo + use Agent + + alias X3m.System.Message + + @spec start_link(opts :: keyword()) :: {:ok, pid :: pid()} + def start_link(_opts \\ []), + do: Agent.start_link(fn -> %{streams: %{}, faults: %{}, delays: %{}} end, name: __MODULE__) + + @doc "Resets all streams and injected faults/delays." + @spec reset() :: :ok + def reset(), + do: Agent.update(__MODULE__, fn _state -> %{streams: %{}, faults: %{}, delays: %{}} end) + + @doc "Forces the next save_events/3 on `stream_name` to return `result` (one-shot)." + @spec fail(stream_name :: String.t(), result :: tuple()) :: :ok + def fail(stream_name, result), + do: Agent.update(__MODULE__, &put_in(&1, [:faults, stream_name], result)) + + @doc "Makes save_events/3 on `stream_name` sleep `ms` before returning." + @spec delay(stream_name :: String.t(), ms :: non_neg_integer()) :: :ok + def delay(stream_name, ms), + do: Agent.update(__MODULE__, &put_in(&1, [:delays, stream_name], ms)) + + @doc "Returns the stored {event, number, metadata} tuples for `stream_name`." + @spec dump(stream_name :: String.t()) :: + [{event :: struct(), number :: integer(), metadata :: map()}] + def dump(stream_name), + do: Agent.get(__MODULE__, &Map.get(&1.streams, stream_name, [])) + + @impl true + def has?(stream_name), + do: Agent.get(__MODULE__, &Map.has_key?(&1.streams, stream_name)) + + @impl true + def stream_events(stream_name, _start_at \\ 0, _per_page \\ 1_000), + do: Agent.get(__MODULE__, &Map.get(&1.streams, stream_name, [])) + + @impl true + def delete_stream(stream_name, _hard_delete?, _expected_version) do + Agent.update(__MODULE__, &%{&1 | streams: Map.delete(&1.streams, stream_name)}) + :ok + end + + @impl true + def save_events(stream_name, %Message{} = message, events_metadata) do + _maybe_delay(stream_name) + + __MODULE__ + |> Agent.get(&Map.get(&1.faults, stream_name)) + |> case do + nil -> + _append(stream_name, message, events_metadata) + + result -> + Agent.update(__MODULE__, &%{&1 | faults: Map.delete(&1.faults, stream_name)}) + result + end + end + + defp _maybe_delay(stream_name) do + __MODULE__ + |> Agent.get(&Map.get(&1.delays, stream_name)) + |> case do + nil -> :ok + ms -> Process.sleep(ms) + end + end + + defp _append(stream_name, %Message{} = message, events_metadata) do + __MODULE__ + |> Agent.get_and_update(fn state -> + existing = Map.get(state.streams, stream_name, []) + current_version = length(existing) - 1 + expected_version = message.aggregate_meta.version + + if expected_version != current_version do + {{:error, :wrong_expected_version, current_version}, state} + else + metadata = Map.put(events_metadata, :message_id, message.id) + + appended = + message.events + |> Enum.with_index(current_version + 1) + |> Enum.map(fn {event, number} -> {event, number, metadata} end) + + last_event_number = current_version + length(message.events) + streams = Map.put(state.streams, stream_name, existing ++ appended) + {{:ok, last_event_number}, %{state | streams: streams}} + end + end) + end +end diff --git a/test/support/account/events.ex b/test/support/account/events.ex index 7cc7beb..fe12e50 100644 --- a/test/support/account/events.ex +++ b/test/support/account/events.ex @@ -1,6 +1,7 @@ defmodule X3m.System.Test.Account.Events do @moduledoc false defmodule Opened, do: defstruct([:id, :owner_id]) + defmodule OwnerChanged, do: defstruct([:id, :owner_id]) defmodule Deposited, do: defstruct([:id, :amount]) defmodule Withdrawn, do: defstruct([:id, :amount]) defmodule WithdrawalRejected, do: defstruct([:id, :amount, :balance]) diff --git a/test/support/account/local_aggregates.ex b/test/support/account/local_aggregates.ex new file mode 100644 index 0000000..6fe401e --- /dev/null +++ b/test/support/account/local_aggregates.ex @@ -0,0 +1,4 @@ +defmodule X3m.System.Test.Account.LocalAggregates do + @moduledoc false + use X3m.System.LocalAggregates, [X3m.System.Test.Account.Aggregate] +end diff --git a/test/support/account/message_handler.ex b/test/support/account/message_handler.ex new file mode 100644 index 0000000..71a3ce2 --- /dev/null +++ b/test/support/account/message_handler.ex @@ -0,0 +1,15 @@ +defmodule X3m.System.Test.Account.MessageHandler do + @moduledoc false + use X3m.System.MessageHandler, + aggregate_mod: X3m.System.Test.Account.Aggregate, + aggregate_repo: X3m.System.Test.Account.EventStore, + stream: "accounts", + pid_facade_mod: X3m.System.AggregatePidFacade, + event_metadata: %{app_version: "test"} + + on_new_aggregate :open_account + on_aggregate :deposit + on_aggregate :withdraw + on_aggregate :close_account + on_maybe_new_aggregate :ensure_account +end diff --git a/test/support/account/reactive_message_handler.ex b/test/support/account/reactive_message_handler.ex new file mode 100644 index 0000000..5b2ce7f --- /dev/null +++ b/test/support/account/reactive_message_handler.ex @@ -0,0 +1,26 @@ +defmodule X3m.System.Test.Account.ReactiveMessageHandler do + @moduledoc false + use X3m.System.MessageHandler, + aggregate_mod: X3m.System.Test.Account.Aggregate, + aggregate_repo: X3m.System.Test.Account.EventStore, + stream: "reactive-accounts", + pid_facade_mod: X3m.System.AggregatePidFacade, + event_metadata: %{app_version: "test"} + + alias X3m.System.Message + alias X3m.System.Test.Account.Events + + on_new_aggregate :open_account + on_aggregate :withdraw + + # React: tell the registered test process what was produced. + # Filter: never persist WithdrawalRejected (a transient fact); persist the rest. + def save_events(%Message{} = message) do + if pid = Process.whereis(:reactive_test_observer), + do: send(pid, {:reacted, message.events}) + + persistable = Enum.reject(message.events, &match?(%Events.WithdrawalRejected{}, &1)) + + super(%{message | events: persistable}) + end +end diff --git a/test/support/account/snapshot_message_handler.ex b/test/support/account/snapshot_message_handler.ex new file mode 100644 index 0000000..bbead1c --- /dev/null +++ b/test/support/account/snapshot_message_handler.ex @@ -0,0 +1,44 @@ +defmodule X3m.System.Test.Account.SnapshotMessageHandler do + @moduledoc false + use X3m.System.MessageHandler, + aggregate_mod: X3m.System.Test.Account.Aggregate, + aggregate_repo: X3m.System.Test.Account.EventStore, + stream: "snapshot-accounts", + pid_facade_mod: X3m.System.AggregatePidFacade, + event_metadata: %{app_version: "test"} + + alias X3m.System.Aggregate.State, as: AggregateState + alias X3m.System.GenAggregate + alias X3m.System.Message + alias X3m.System.Test.Account.StateStore + + on_new_aggregate :open_account + on_aggregate :deposit + + # State-based (non-ES): events are not persisted to an event store; the version simply + # advances by the number of produced events (what an event store would have returned). + def save_events(%Message{events: events, aggregate_meta: %{version: version}}), + do: {:ok, version + length(events)} + + # Persist the FULL state (client_state + version) after commit. + def save_state(id, %AggregateState{version: version, client_state: client_state}, %Message{}) do + StateStore.put(id, {client_state, version}) + :ok + end + + # Hydrate a fresh process from the saved state (no replay): the aggregate's `set_state/1` + # callback rebuilds client_state and the framework restores the version. + def when_pid_is_not_registered(_aggregate_mod, id, spawn_new_fun) do + id + |> StateStore.get() + |> case do + {:ok, {client_state, version}} -> + {:ok, pid} = spawn_new_fun.() + :ok = GenAggregate.set_state(pid, client_state, version) + {:ok, pid} + + :not_found -> + {:error, :not_found} + end + end +end diff --git a/test/support/account/state_store.ex b/test/support/account/state_store.ex new file mode 100644 index 0000000..06b885b --- /dev/null +++ b/test/support/account/state_store.ex @@ -0,0 +1,31 @@ +defmodule X3m.System.Test.Account.StateStore do + @moduledoc false + # SQL-like in-memory store of a saved row per aggregate id (non-ES). The stored value is + # opaque (e.g. the {client_state, version} pair the snapshot handler persists). + use Agent + + @spec start_link(opts :: keyword()) :: {:ok, pid :: pid()} + def start_link(_opts \\ []), + do: Agent.start_link(fn -> %{} end, name: __MODULE__) + + @doc "Clears all stored rows." + @spec reset() :: :ok + def reset(), + do: Agent.update(__MODULE__, fn _state -> %{} end) + + @doc "Upserts the `value` row under `id`." + @spec put(id :: String.t(), value :: term()) :: :ok + def put(id, value), + do: Agent.update(__MODULE__, &Map.put(&1, id, value)) + + @doc "Loads the row for `id`, or `:not_found`." + @spec get(id :: String.t()) :: {:ok, value :: term()} | :not_found + def get(id) do + __MODULE__ + |> Agent.get(&Map.fetch(&1, id)) + |> case do + {:ok, value} -> {:ok, value} + :error -> :not_found + end + end +end diff --git a/test/support/account/timeout_message_handler.ex b/test/support/account/timeout_message_handler.ex new file mode 100644 index 0000000..a72f3fa --- /dev/null +++ b/test/support/account/timeout_message_handler.ex @@ -0,0 +1,12 @@ +defmodule X3m.System.Test.Account.TimeoutMessageHandler do + @moduledoc false + use X3m.System.MessageHandler, + aggregate_mod: X3m.System.Test.Account.Aggregate, + aggregate_repo: X3m.System.Test.Account.EventStore, + stream: "timeout-accounts", + pid_facade_mod: X3m.System.AggregatePidFacade, + event_metadata: %{app_version: "test"} + + on_new_aggregate :open_account, commit_timeout: 50 + on_aggregate :deposit, commit_timeout: 50 +end diff --git a/test/support/account/unload_on_events_handler.ex b/test/support/account/unload_on_events_handler.ex new file mode 100644 index 0000000..60fbbc0 --- /dev/null +++ b/test/support/account/unload_on_events_handler.ex @@ -0,0 +1,13 @@ +defmodule X3m.System.Test.Account.UnloadOnEventsHandler do + @moduledoc false + use X3m.System.MessageHandler, + aggregate_mod: X3m.System.Test.Account.Aggregate, + aggregate_repo: X3m.System.Test.Account.EventStore, + stream: "unload-evt-accounts", + pid_facade_mod: X3m.System.AggregatePidFacade, + event_metadata: %{app_version: "test"}, + unload_aggregate_on: %{events: %{X3m.System.Test.Account.Events.Deposited => {:in, 30}}} + + on_new_aggregate :open_account + on_aggregate :deposit +end diff --git a/test/support/account/unload_on_partial_state_handler.ex b/test/support/account/unload_on_partial_state_handler.ex new file mode 100644 index 0000000..380d330 --- /dev/null +++ b/test/support/account/unload_on_partial_state_handler.ex @@ -0,0 +1,18 @@ +defmodule X3m.System.Test.Account.UnloadOnPartialStateHandler do + @moduledoc false + alias X3m.System.Test.Account.State + + use X3m.System.MessageHandler, + aggregate_mod: X3m.System.Test.Account.Aggregate, + aggregate_repo: X3m.System.Test.Account.EventStore, + stream: "unload-partial-accounts", + pid_facade_mod: X3m.System.AggregatePidFacade, + event_metadata: %{app_version: "test"}, + unload_aggregate_on: %{state: &__MODULE__.only_closed/1} + + on_new_aggregate :open_account + on_aggregate :deposit + + # Intentionally no clause for a non-closed state -> FunctionClauseError -> rescued to :skip. + def only_closed(%State{closed?: true}), do: :unload +end diff --git a/test/support/account/unload_on_state_handler.ex b/test/support/account/unload_on_state_handler.ex new file mode 100644 index 0000000..ad45ae4 --- /dev/null +++ b/test/support/account/unload_on_state_handler.ex @@ -0,0 +1,20 @@ +defmodule X3m.System.Test.Account.UnloadOnStateHandler do + @moduledoc false + alias X3m.System.Test.Account.State + + use X3m.System.MessageHandler, + aggregate_mod: X3m.System.Test.Account.Aggregate, + aggregate_repo: X3m.System.Test.Account.EventStore, + stream: "unload-state-accounts", + pid_facade_mod: X3m.System.AggregatePidFacade, + event_metadata: %{app_version: "test"}, + unload_aggregate_on: %{state: &__MODULE__.unload_on_state/1} + + on_new_aggregate :open_account + on_aggregate :deposit + on_aggregate :close_account + + def unload_on_state(%State{closed?: true}), do: :unload + def unload_on_state(%State{balance: balance}) when balance > 1_000, do: {:in, 30} + def unload_on_state(%State{}), do: :skip +end diff --git a/test/test_helper.exs b/test/test_helper.exs index e11ce8f..773709a 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -5,4 +5,13 @@ ExUnit.start() +{:ok, _} = X3m.System.Test.Account.EventStore.start_link() +{:ok, _} = X3m.System.Test.Account.StateStore.start_link() + +{:ok, _} = + X3m.System.LocalAggregatesSupervision.start_link([ + X3m.System.Test.Account.LocalAggregates, + X3m.System.Test + ]) + :ok = X3m.System.Test.Router.register_services()