diff --git a/OPTIONS.md b/OPTIONS.md index 8b612f99..6b530594 100644 --- a/OPTIONS.md +++ b/OPTIONS.md @@ -7,6 +7,9 @@ Please see [`syn_event_handler`](syn_event_handler.html) for information on call The event handler module can be defined either by using the [`set_event_handler/1`](syn.html#set_event_handler/1) method, or by setting the `event_handler` configuration variable. +Syn does not call this module for a Scope that has its own handler, set with +[`set_event_handler/2`](syn.html#set_event_handler/2) or with the [`scope_event_handlers`](#scope_event_handlers) key. + #### Elixir ```elixir @@ -22,6 +25,39 @@ config :syn, ]} ``` +## scope_event_handlers +Please see [`syn_event_handler`](syn_event_handler.html) for information on callbacks. + +The `scope_event_handlers` configuration variable sets the event handler modules of individual Scopes, as a list of +`{Scope, Module}` tuples. It is meant for Scopes known before Syn starts, typically those listed under `scopes`. +Syn starts those Scopes during its own startup, usually before your application code runs. It applies this list before +it starts any Scope, so the handlers are in place for the first events of these Scopes. If an entry is malformed or +names a module that cannot be loaded, Syn fails to start. + +Syn applies the list every time it starts, and an entry replaces a handler set earlier for that Scope with +[`set_event_handler/2`](syn.html#set_event_handler/2). For a Scope added at runtime, call +[`set_event_handler/2`](syn.html#set_event_handler/2) before [`add_node_to_scopes/1`](syn.html#add_node_to_scopes/1). + +Syn uses a Scope's own handler for all of the Scope's callbacks, otherwise the node-wide `event_handler`, and otherwise +its defaults. + +#### Elixir + +```elixir +config :syn, + scopes: [:devices, :users], + scope_event_handlers: [users: MyUsersEventHandler] +``` + +#### Erlang + +```erlang +{syn, [ + {scopes, [devices, users]}, + {scope_event_handlers, [{users, my_users_event_handler}]} +]} +``` + ## scopes Please see [`here`](syn.html) for information on Scopes. diff --git a/src/syn.erl b/src/syn.erl index 7a81cbc5..7e1990ce 100755 --- a/src/syn.erl +++ b/src/syn.erl @@ -57,7 +57,7 @@ %% scopes -export([node_scopes/0, add_node_to_scopes/1]). -export([subcluster_nodes/2]). --export([set_event_handler/1]). +-export([set_event_handler/1, set_event_handler/2]). %% registry -export([lookup/2]). -export([register/3, register/4, update_registry/3]). @@ -152,6 +152,9 @@ subcluster_nodes(pg, Scope) -> %% %% Please see {@link syn_event_handler} for information on callbacks. %% +%% Syn does not call this module for a Scope that has its own handler, set with {@link set_event_handler/2} or with +%% the `scope_event_handlers' key. +%% %% There are 2 ways to set a handler module. One is by using this method, the other is to set the environment variable `syn' %% with the key `event_handler'. In this latter case, you're probably best off using an application configuration file: %% @@ -184,6 +187,58 @@ set_event_handler(Module) -> %% ensure event handler is loaded syn_event_handler:ensure_event_handler_loaded(). +%% @doc Sets the handler module for the specified `Scope'. +%% +%% Please see {@link syn_event_handler} for information on callbacks. +%% +%% Syn selects the handler for a Scope as a whole, not per callback: +%% +%% +%% You can call this method before Syn is started and before the node is added to the Scope. +%% The handler stays set when the `syn' application stops and starts again. +%% Calling it again for the same Scope replaces the handler. +%% +%% There are 2 ways to set the handler module of a Scope. One is by using this method, the other is to set the environment +%% variable `syn' with the key `scope_event_handlers', a list of `{Scope, Module}' tuples. Syn applies this list every time +%% it starts, before it starts any Scope, so use it for the Scopes listed under the `scopes' key: Syn starts them during its +%% own startup, which usually happens before your code runs. An entry in the list replaces a handler set earlier with this +%% method for the same Scope. If an entry is malformed or names a module that cannot be loaded, Syn fails to start. +%% +%%

Elixir

+%% ``` +%% config :syn, +%% scope_event_handlers: [devices: MyDevicesEventHandler] +%% ''' +%%

Erlang

+%% ``` +%% {syn, [ +%% {scope_event_handlers, [{devices, my_devices_event_handler}]} +%% ]} +%% ''' +%% +%%

Examples

+%%

Elixir

+%% ``` +%% iex> :syn.set_event_handler(:devices, MyDevicesEventHandler) +%% :ok +%% ''' +%%

Erlang

+%% ``` +%% 1> syn:set_event_handler(devices, my_devices_event_handler). +%% ok +%% ''' +-spec set_event_handler(Scope :: atom(), Module :: module()) -> ok. +set_event_handler(Scope, Module) when is_atom(Scope), is_atom(Module), Module =/= undefined -> + syn_event_handler:set_scope_event_handler(Scope, Module). + %% ----- \/ registry ------------------------------------------------- %% @doc Looks up a registry entry in the specified Scope. %% diff --git a/src/syn_app.erl b/src/syn_app.erl index 3fb8f38f..8ed65bfa 100755 --- a/src/syn_app.erl +++ b/src/syn_app.erl @@ -40,6 +40,8 @@ start(_StartType, _StartArgs) -> %% ensure event handler is loaded syn_event_handler:ensure_event_handler_loaded(), + %% set scope event handlers from the configuration before any scope starts + syn_event_handler:set_configured_scope_event_handlers(), %% start main sup syn_sup:start_link(). diff --git a/src/syn_event_handler.erl b/src/syn_event_handler.erl index 500ad51e..e036fbb5 100644 --- a/src/syn_event_handler.erl +++ b/src/syn_event_handler.erl @@ -30,7 +30,19 @@ %% =================================================================== %% @doc Defines Syn's callbacks. %% -%% You can specify the callback module with {@link syn:set_event_handler/1}. +%% You can specify a callback module for the whole node with {@link syn:set_event_handler/1}, and a callback module +%% for a single Scope with {@link syn:set_event_handler/2} or with the `scope_event_handlers' key of the `syn' +%% application environment. Syn selects the module for each Scope as a whole: +%% +%% A node-wide module therefore receives no callbacks from Scopes that have their own module. +%% %% In your module you need to specify the behavior `syn_event_handler' and implement the callbacks. %% All callbacks are optional, so you just need to define the ones you need. %% @@ -74,8 +86,9 @@ %% one of the two Pids, or if the method crashes, none of the Pids will be killed and the conflicting name will be freed. %% %% Important Note: the conflict resolution method will be called on the two nodes where the conflicting processes are running on. -%% Therefore, this method MUST be defined in the same way across all nodes of the cluster and have the same effect -%% regardless of the node it is run on, or you will experience unexpected results. +%% Therefore, every node must resolve a conflict in a Scope the same way, which in practice means the same module and +%% version. This method MUST be defined in the same way across all nodes and have the same effect regardless of the +%% node it is run on, or you will experience unexpected results. %% %%

Examples

%% The following callback module implements the `on_process_unregistered/5' and the `on_process_left/5' callbacks. @@ -130,6 +143,8 @@ %% API -export([ensure_event_handler_loaded/0]). +-export([set_scope_event_handler/2]). +-export([set_configured_scope_event_handlers/0]). -export([call_event_handler/2]). -export([do_resolve_registry_conflict/4]). @@ -217,18 +232,46 @@ -spec ensure_event_handler_loaded() -> ok. ensure_event_handler_loaded() -> %% get handler - CustomEventHandler = get_custom_event_handler(), - %% ensure that is it loaded (not using code:ensure_loaded/1 to support embedded mode) - catch CustomEventHandler:module_info(exports), - ok. + CustomEventHandler = get_node_wide_event_handler(), + ensure_module_loaded(CustomEventHandler). + +%% @private +-spec set_scope_event_handler(Scope :: atom(), Module :: module()) -> ok. +set_scope_event_handler(Scope, Module) -> + %% in interactive mode this loads the module before events can reach it; + %% in embedded mode the release has already loaded it, and nothing here can load it + ensure_module_loaded(Module), + persistent_term:put({syn_event_handler, Scope}, Module). + +%% @private +-spec set_configured_scope_event_handlers() -> ok. +set_configured_scope_event_handlers() -> + set_configured_scope_event_handlers(application:get_env(syn, scope_event_handlers, [])). + +-spec set_configured_scope_event_handlers(Entries :: [{Scope :: atom(), Module :: module()}]) -> ok. +set_configured_scope_event_handlers(Entries) when is_list(Entries) -> + %% a malformed value or entry crashes with function_clause, so that syn fails to start + lists:foreach(fun({Scope, Module}) when is_atom(Scope), is_atom(Module), Module =/= undefined -> + %% in interactive mode this loads the module if it can; + %% in embedded mode the boot script has already loaded every module of the release + ensure_module_loaded(Module), + case erlang:module_loaded(Module) of + true -> + persistent_term:put({syn_event_handler, Scope}, Module); + + false -> + %% fail the start: the scope would skip every callback and resolve its conflicts with the defaults + error({scope_event_handler_not_loaded, Scope, Module}) + end + end, Entries). %% @private -spec call_event_handler( CallbackMethod :: atom(), - Args :: [term()] + Args :: [term(), ...] ) -> any(). -call_event_handler(CallbackMethod, Args) -> - CustomEventHandler = get_custom_event_handler(), +call_event_handler(CallbackMethod, [Scope | _] = Args) -> + CustomEventHandler = get_custom_event_handler(Scope), case erlang:function_exported(CustomEventHandler, CallbackMethod, length(Args)) of true -> try apply(CustomEventHandler, CallbackMethod, Args) @@ -251,7 +294,7 @@ call_event_handler(CallbackMethod, Args) -> {Pid2 :: pid(), Meta2 :: term(), Time2 :: non_neg_integer()} ) -> {PidToKeep :: pid() | undefined, KillOtherPid :: boolean()}. do_resolve_registry_conflict(Scope, Name, {Pid1, Meta1, Time1}, {Pid2, Meta2, Time2}) -> - CustomEventHandler = get_custom_event_handler(), + CustomEventHandler = get_custom_event_handler(Scope), case erlang:function_exported(CustomEventHandler, resolve_registry_conflict, 4) of true -> try CustomEventHandler:resolve_registry_conflict(Scope, Name, {Pid1, Meta1, Time1}, {Pid2, Meta2, Time2}) of @@ -283,6 +326,20 @@ do_resolve_registry_conflict(Scope, Name, {Pid1, Meta1, Time1}, {Pid2, Meta2, Ti %% =================================================================== %% Internal %% =================================================================== --spec get_custom_event_handler() -> undefined | module(). -get_custom_event_handler() -> +-spec get_custom_event_handler(Scope :: atom()) -> undefined | module(). +get_custom_event_handler(Scope) -> + %% a scope handler replaces the node-wide handler for all callbacks of its scope + case persistent_term:get({syn_event_handler, Scope}, undefined) of + undefined -> get_node_wide_event_handler(); + Module -> Module + end. + +-spec get_node_wide_event_handler() -> undefined | module(). +get_node_wide_event_handler() -> application:get_env(syn, event_handler, undefined). + +-spec ensure_module_loaded(Module :: undefined | module()) -> ok. +ensure_module_loaded(Module) -> + %% not using code:ensure_loaded/1 to support embedded mode + catch Module:module_info(exports), + ok. diff --git a/test/syn_pg_SUITE.erl b/test/syn_pg_SUITE.erl index fafc40d3..b8e3b210 100644 --- a/test/syn_pg_SUITE.erl +++ b/test/syn_pg_SUITE.erl @@ -33,7 +33,8 @@ %% tests -export([ - one_node_strict_mode/1 + one_node_strict_mode/1, + one_node_scope_event_handler_joined_left/1 ]). -export([ two_nodes_member_count/1 @@ -98,7 +99,8 @@ all() -> groups() -> [ {one_node_pg, [shuffle], [ - one_node_strict_mode + one_node_strict_mode, + one_node_scope_event_handler_joined_left ]}, {two_nodes_pg, [shuffle], [ two_nodes_member_count @@ -223,6 +225,38 @@ one_node_strict_mode(_Config) -> [{Self, undefined}] = syn:members(scope, "strict"), ok = syn:leave(scope, "strict", Self). +one_node_scope_event_handler_joined_left(_Config) -> + %% start syn + ok = syn:start(), + + %% node-wide handler, and an own handler for one scope + ok = syn:set_event_handler(syn_test_event_handler_callbacks), + ok = syn:set_event_handler(scope_with_handler, syn_test_event_handler_scope_callbacks), + ok = syn:add_node_to_scopes([scope_with_handler, scope_without_handler]), + + %% init + LocalNode = node(), + Pid = syn_test_suite_helper:start_process(), + + %% join, update & leave in both scopes + %% (the scope handler does not implement the update callbacks, the node-wide handler does) + ok = syn:join(scope_with_handler, "group-handler", Pid, {recipient, self(), <<"meta">>}), + ok = syn:join(scope_without_handler, "group-handler", Pid, {recipient, self(), <<"meta">>}), + ok = syn:join(scope_with_handler, "group-handler", Pid, {recipient, self(), <<"new-meta">>}), + ok = syn:join(scope_without_handler, "group-handler", Pid, {recipient, self(), <<"new-meta">>}), + ok = syn:leave(scope_with_handler, "group-handler", Pid), + ok = syn:leave(scope_without_handler, "group-handler", Pid), + + %% check that each scope's callbacks reach only one handler + syn_test_suite_helper:assert_received_messages([ + {on_process_joined, syn_test_event_handler_scope_callbacks, LocalNode, scope_with_handler, "group-handler", Pid, <<"meta">>, normal}, + {on_process_left, syn_test_event_handler_scope_callbacks, LocalNode, scope_with_handler, "group-handler", Pid, <<"new-meta">>, normal}, + {on_process_joined, LocalNode, scope_without_handler, "group-handler", Pid, <<"meta">>, normal}, + {on_group_process_updated, LocalNode, scope_without_handler, "group-handler", Pid, <<"new-meta">>, normal}, + {on_group_process_updated, LocalNode, scope_without_handler, "group-handler", Pid, <<"meta">>, <<"new-meta">>, normal}, + {on_process_left, LocalNode, scope_without_handler, "group-handler", Pid, <<"new-meta">>, normal} + ]). + two_nodes_member_count(Config) -> %% get slave SlaveNode1 = proplists:get_value(syn_slave_1, Config), diff --git a/test/syn_registry_SUITE.erl b/test/syn_registry_SUITE.erl index 63651203..af2e23eb 100644 --- a/test/syn_registry_SUITE.erl +++ b/test/syn_registry_SUITE.erl @@ -36,7 +36,16 @@ one_node_via_register_unregister/1, one_node_via_register_unregister_with_metadata/1, one_node_strict_mode/1, - one_node_repeated_restart/1 + one_node_repeated_restart/1, + one_node_scope_event_handler_reg_unreg/1, + one_node_scope_event_handler_loads_module/1, + one_node_scope_event_handler_set_before_start/1, + one_node_scope_event_handler_invalid_args/1, + one_node_scope_event_handlers_config/1, + one_node_scope_event_handlers_config_loads_module/1, + one_node_scope_event_handlers_config_replaces_handler/1, + one_node_scope_event_handlers_config_invalid_entry/1, + one_node_scope_event_handlers_config_module_not_loaded/1 ]). -export([ three_nodes_discover/1, @@ -46,6 +55,8 @@ three_nodes_cluster_conflicts/1, three_nodes_custom_event_handler_reg_unreg/1, three_nodes_custom_event_handler_conflict_resolution/1, + three_nodes_scope_event_handler_conflict_resolution/1, + three_nodes_scope_event_handlers_config_boot_sync/1, three_nodes_update/1, three_nodes_ack_sync_reconciles_registry_snapshot/1, three_nodes_ack_sync_ordered_delivery/1 @@ -94,7 +105,16 @@ groups() -> one_node_via_register_unregister, one_node_via_register_unregister_with_metadata, one_node_strict_mode, - one_node_repeated_restart + one_node_repeated_restart, + one_node_scope_event_handler_reg_unreg, + one_node_scope_event_handler_loads_module, + one_node_scope_event_handler_set_before_start, + one_node_scope_event_handler_invalid_args, + one_node_scope_event_handlers_config, + one_node_scope_event_handlers_config_loads_module, + one_node_scope_event_handlers_config_replaces_handler, + one_node_scope_event_handlers_config_invalid_entry, + one_node_scope_event_handlers_config_module_not_loaded ]}, {three_nodes_registry, [shuffle], [ three_nodes_discover, @@ -104,6 +124,8 @@ groups() -> three_nodes_cluster_conflicts, three_nodes_custom_event_handler_reg_unreg, three_nodes_custom_event_handler_conflict_resolution, + three_nodes_scope_event_handler_conflict_resolution, + three_nodes_scope_event_handlers_config_boot_sync, three_nodes_update, three_nodes_ack_sync_reconciles_registry_snapshot, three_nodes_ack_sync_ordered_delivery @@ -299,6 +321,195 @@ one_node_repeated_restart(_Config) -> end, lists:foreach(StartStop, lists:duplicate(RepeatCount, 0)). +one_node_scope_event_handler_reg_unreg(_Config) -> + %% start syn + ok = syn:start(), + + %% node-wide handler, and an own handler for one scope + ok = syn:set_event_handler(syn_test_event_handler_callbacks), + ok = syn:set_event_handler(scope_with_handler, syn_test_event_handler_scope_callbacks), + ok = syn:add_node_to_scopes([scope_with_handler, scope_without_handler]), + + %% init + LocalNode = node(), + Pid = syn_test_suite_helper:start_process(), + + %% register, update & unregister in both scopes + %% (the scope handler does not implement the update callbacks, the node-wide handler does) + ok = syn:register(scope_with_handler, "proc-handler", Pid, {recipient, self(), <<"meta">>}), + ok = syn:register(scope_without_handler, "proc-handler", Pid, {recipient, self(), <<"meta">>}), + ok = syn:register(scope_with_handler, "proc-handler", Pid, {recipient, self(), <<"new-meta">>}), + ok = syn:register(scope_without_handler, "proc-handler", Pid, {recipient, self(), <<"new-meta">>}), + ok = syn:unregister(scope_with_handler, "proc-handler"), + ok = syn:unregister(scope_without_handler, "proc-handler"), + + %% check that each scope's callbacks reach only one handler + syn_test_suite_helper:assert_received_messages([ + {on_process_registered, syn_test_event_handler_scope_callbacks, LocalNode, scope_with_handler, "proc-handler", Pid, <<"meta">>, normal}, + {on_process_unregistered, syn_test_event_handler_scope_callbacks, LocalNode, scope_with_handler, "proc-handler", Pid, <<"new-meta">>, normal}, + {on_process_registered, LocalNode, scope_without_handler, "proc-handler", Pid, <<"meta">>, normal}, + {on_registry_process_updated, LocalNode, scope_without_handler, "proc-handler", Pid, <<"new-meta">>, normal}, + {on_registry_process_updated, LocalNode, scope_without_handler, "proc-handler", Pid, <<"meta">>, <<"new-meta">>, normal}, + {on_process_unregistered, LocalNode, scope_without_handler, "proc-handler", Pid, <<"new-meta">>, normal} + ]). + +one_node_scope_event_handler_loads_module(_Config) -> + %% unload handler module + code:purge(syn_test_event_handler_scope_callbacks), + code:delete(syn_test_event_handler_scope_callbacks), + code:purge(syn_test_event_handler_scope_callbacks), + false = erlang:module_loaded(syn_test_event_handler_scope_callbacks), + + %% start syn + ok = syn:start(), + ok = syn:add_node_to_scopes([scope_with_handler]), + + %% set handler + ok = syn:set_event_handler(scope_with_handler, syn_test_event_handler_scope_callbacks), + true = erlang:module_loaded(syn_test_event_handler_scope_callbacks), + + %% register + LocalNode = node(), + Pid = syn_test_suite_helper:start_process(), + ok = syn:register(scope_with_handler, "proc-handler", Pid, {recipient, self(), <<"meta">>}), + + %% check callbacks called + syn_test_suite_helper:assert_received_messages([ + {on_process_registered, syn_test_event_handler_scope_callbacks, LocalNode, scope_with_handler, "proc-handler", Pid, <<"meta">>, normal} + ]). + +one_node_scope_event_handler_set_before_start(_Config) -> + %% set handler before syn and the scope exist + ok = syn:set_event_handler(scope_with_handler, syn_test_event_handler_scope_callbacks), + + %% start syn + ok = syn:start(), + ok = syn:add_node_to_scopes([scope_with_handler]), + + %% register + LocalNode = node(), + Pid = syn_test_suite_helper:start_process(), + ok = syn:register(scope_with_handler, "proc-handler", Pid, {recipient, self(), <<"meta">>}), + + %% check callbacks called + syn_test_suite_helper:assert_received_messages([ + {on_process_registered, syn_test_event_handler_scope_callbacks, LocalNode, scope_with_handler, "proc-handler", Pid, <<"meta">>, normal} + ]). + +one_node_scope_event_handler_invalid_args(_Config) -> + {'EXIT', {function_clause, _}} = (catch syn:set_event_handler(<<"scope">>, syn_test_event_handler_scope_callbacks)), + {'EXIT', {function_clause, _}} = (catch syn:set_event_handler(scope_with_handler, "syn_test_event_handler_scope_callbacks")), + {'EXIT', {function_clause, _}} = (catch syn:set_event_handler(scope_with_handler, undefined)), + %% a test case that returns {'EXIT', _} counts as failed + ok. + +one_node_scope_event_handlers_config(_Config) -> + %% configure scopes and handlers before syn starts + application:set_env(syn, event_handler, syn_test_event_handler_callbacks), + application:set_env(syn, scopes, [scope_with_handler, scope_without_handler]), + application:set_env(syn, scope_event_handlers, [{scope_with_handler, syn_test_event_handler_scope_callbacks}]), + + %% start syn + ok = syn:start(), + + %% init + LocalNode = node(), + Pid = syn_test_suite_helper:start_process(), + + %% register & unregister in both scopes + ok = syn:register(scope_with_handler, "proc-handler", Pid, {recipient, self(), <<"meta">>}), + ok = syn:register(scope_without_handler, "proc-handler", Pid, {recipient, self(), <<"meta">>}), + ok = syn:unregister(scope_with_handler, "proc-handler"), + ok = syn:unregister(scope_without_handler, "proc-handler"), + + %% check that each scope's callbacks reach only one handler + syn_test_suite_helper:assert_received_messages([ + {on_process_registered, syn_test_event_handler_scope_callbacks, LocalNode, scope_with_handler, "proc-handler", Pid, <<"meta">>, normal}, + {on_process_unregistered, syn_test_event_handler_scope_callbacks, LocalNode, scope_with_handler, "proc-handler", Pid, <<"meta">>, normal}, + {on_process_registered, LocalNode, scope_without_handler, "proc-handler", Pid, <<"meta">>, normal}, + {on_process_unregistered, LocalNode, scope_without_handler, "proc-handler", Pid, <<"meta">>, normal} + ]). + +one_node_scope_event_handlers_config_loads_module(_Config) -> + %% unload handler module + code:purge(syn_test_event_handler_scope_callbacks), + code:delete(syn_test_event_handler_scope_callbacks), + code:purge(syn_test_event_handler_scope_callbacks), + false = erlang:module_loaded(syn_test_event_handler_scope_callbacks), + + %% configure handler + application:set_env(syn, scope_event_handlers, [{scope_with_handler, syn_test_event_handler_scope_callbacks}]), + + %% start syn + ok = syn:start(), + true = erlang:module_loaded(syn_test_event_handler_scope_callbacks). + +one_node_scope_event_handlers_config_replaces_handler(_Config) -> + %% set handlers before syn starts, then configure another handler for one of the scopes + ok = syn:set_event_handler(scope_with_handler, syn_test_event_handler_callbacks), + ok = syn:set_event_handler(scope_with_runtime_handler, syn_test_event_handler_callbacks), + application:set_env(syn, scope_event_handlers, [{scope_with_handler, syn_test_event_handler_scope_callbacks}]), + + %% start syn + ok = syn:start(), + ok = syn:add_node_to_scopes([scope_with_handler, scope_with_runtime_handler]), + + %% register in both scopes + LocalNode = node(), + Pid = syn_test_suite_helper:start_process(), + ok = syn:register(scope_with_handler, "proc-handler", Pid, {recipient, self(), <<"meta">>}), + ok = syn:register(scope_with_runtime_handler, "proc-handler", Pid, {recipient, self(), <<"meta">>}), + + %% the configured scope uses the configured handler, the other scope keeps its handler + syn_test_suite_helper:assert_received_messages([ + {on_process_registered, syn_test_event_handler_scope_callbacks, LocalNode, scope_with_handler, "proc-handler", Pid, <<"meta">>, normal}, + {on_process_registered, LocalNode, scope_with_runtime_handler, "proc-handler", Pid, <<"meta">>, normal} + ]). + +one_node_scope_event_handlers_config_invalid_entry(_Config) -> + lists:foreach(fun(BadEntry) -> + application:set_env(syn, scope_event_handlers, [BadEntry]), + {'EXIT', {{badmatch, {error, {syn, StartError}}}, _}} = (catch syn:start()), + %% the start fails on the bad entry itself + {bad_return, {_, {'EXIT', {function_clause, [{syn_event_handler, _, [BadEntry], _} | _]}}}} = StartError + end, [ + {<<"scope">>, syn_test_event_handler_scope_callbacks}, + {scope_with_handler, "syn_test_event_handler_scope_callbacks"}, + {scope_with_handler, undefined}, + scope_with_handler, + {scope_with_handler, syn_test_event_handler_scope_callbacks, extra} + ]), + + %% a value that is not a list fails in syn as well, on the value itself + BadValue = #{scope_with_handler => syn_test_event_handler_scope_callbacks}, + application:set_env(syn, scope_event_handlers, BadValue), + {'EXIT', {{badmatch, {error, {syn, StartError}}}, _}} = (catch syn:start()), + {bad_return, {_, {'EXIT', {function_clause, [{syn_event_handler, _, [BadValue], _} | _]}}}} = StartError. + +one_node_scope_event_handlers_config_module_not_loaded(_Config) -> + %% configure a node-wide handler, and a scope handler module that does not exist + application:set_env(syn, event_handler, syn_test_event_handler_callbacks), + application:set_env(syn, scope_event_handlers, [{scope_with_handler, syn_test_event_handler_missing}]), + + %% the start fails on the module that cannot be loaded + {'EXIT', {{badmatch, {error, {syn, StartError}}}, _}} = (catch syn:start()), + {bad_return, {_, {'EXIT', {{scope_event_handler_not_loaded, scope_with_handler, syn_test_event_handler_missing}, _}}}} = StartError, + + %% start syn without the entry + application:unset_env(syn, scope_event_handlers), + ok = syn:start(), + ok = syn:add_node_to_scopes([scope_with_handler]), + + %% register + LocalNode = node(), + Pid = syn_test_suite_helper:start_process(), + ok = syn:register(scope_with_handler, "proc-handler", Pid, {recipient, self(), <<"meta">>}), + + %% the failed entry left no handler behind, so the callback reaches the node-wide handler + syn_test_suite_helper:assert_received_messages([ + {on_process_registered, LocalNode, scope_with_handler, "proc-handler", Pid, <<"meta">>, normal} + ]). + three_nodes_discover(Config) -> %% get slaves SlaveNode1 = proplists:get_value(syn_slave_1, Config), @@ -1528,6 +1739,145 @@ three_nodes_custom_event_handler_conflict_resolution(Config) -> fun() -> rpc:call(SlaveNode2, erlang, is_process_alive, [PidOn2]) end ). +three_nodes_scope_event_handler_conflict_resolution(Config) -> + %% get slaves + SlaveNode1 = proplists:get_value(syn_slave_1, Config), + SlaveNode2 = proplists:get_value(syn_slave_2, Config), + Nodes = [node(), SlaveNode1, SlaveNode2], + Scopes = [scope_with_resolver, scope_without_handler, scope_without_resolver], + + %% start syn on nodes + ok = syn:start(), + ok = rpc:call(SlaveNode1, syn, start, []), + ok = rpc:call(SlaveNode2, syn, start, []), + + %% set handlers and add scopes on all nodes (scope_without_handler has no handler of its own) + lists:foreach(fun(Node) -> + ok = rpc:call(Node, syn, set_event_handler, [syn_test_event_handler_node_wide_resolution]), + ok = rpc:call(Node, syn, set_event_handler, [scope_with_resolver, syn_test_event_handler_scope_resolution]), + ok = rpc:call(Node, syn, set_event_handler, [scope_without_resolver, syn_test_event_handler_scope_callbacks]), + ok = rpc:call(Node, syn, add_node_to_scopes, [Scopes]) + end, Nodes), + + %% wait for full scope discovery + lists:foreach(fun(Scope) -> + lists:foreach(fun(Node) -> + syn_test_suite_helper:assert_scope_subcluster(registry, Node, Scope, Nodes -- [Node]) + end, Nodes) + end, Scopes), + + %% current node + TestPid = self(), + LocalNode = node(), + + %% partial netsplit (1 cannot see 2) + rpc:call(SlaveNode1, syn_test_suite_helper, disconnect_node, [SlaveNode2]), + syn_test_suite_helper:assert_cluster(node(), [SlaveNode1, SlaveNode2]), + syn_test_suite_helper:assert_cluster(SlaveNode1, [node()]), + syn_test_suite_helper:assert_cluster(SlaveNode2, [node()]), + + %% start conflict processes + PidOn1 = syn_test_suite_helper:start_process(SlaveNode1), + PidOn2 = syn_test_suite_helper:start_process(SlaveNode2), + DefaultRulePidOn1 = syn_test_suite_helper:start_process(SlaveNode1), + DefaultRulePidOn2 = syn_test_suite_helper:start_process(SlaveNode2), + monitor(process, PidOn1), + DefaultRulePidOn1MRef = monitor(process, DefaultRulePidOn1), + + %% --> conflict by netsplit, in every scope the registration on node 2 is the later one + %% the marker in the metadata names the resolver that keeps the process, so the two resolvers disagree in every scope + ok = rpc:call(SlaveNode1, syn, register, [scope_with_resolver, "proc-conflict", PidOn1, {recipient, TestPid, node_wide_keeps}]), + ok = rpc:call(SlaveNode1, syn, register, [scope_without_handler, "proc-conflict", PidOn1, {recipient, TestPid, scope_keeps}]), + ok = rpc:call(SlaveNode1, syn, register, [scope_without_resolver, "proc-conflict", DefaultRulePidOn1, {recipient, TestPid, node_wide_keeps}]), + %% the local node must get the registrations on node 1 before those on node 2, or it drops them as older + syn_test_suite_helper:assert_wait( + {PidOn1, {recipient, TestPid, node_wide_keeps}}, + fun() -> syn:lookup(scope_with_resolver, "proc-conflict") end + ), + syn_test_suite_helper:assert_wait( + {PidOn1, {recipient, TestPid, scope_keeps}}, + fun() -> syn:lookup(scope_without_handler, "proc-conflict") end + ), + syn_test_suite_helper:assert_wait( + {DefaultRulePidOn1, {recipient, TestPid, node_wide_keeps}}, + fun() -> syn:lookup(scope_without_resolver, "proc-conflict") end + ), + ok = rpc:call(SlaveNode2, syn, register, [scope_with_resolver, "proc-conflict", PidOn2, {recipient, TestPid, scope_keeps}]), + ok = rpc:call(SlaveNode2, syn, register, [scope_without_handler, "proc-conflict", PidOn2, {recipient, TestPid, node_wide_keeps}]), + ok = rpc:call(SlaveNode2, syn, register, [scope_without_resolver, "proc-conflict", DefaultRulePidOn2, {recipient, TestPid, scope_keeps}]), + + %% check callbacks (only the handler of scope_without_resolver implements them) + syn_test_suite_helper:assert_received_messages([ + {on_process_registered, syn_test_event_handler_scope_callbacks, LocalNode, scope_without_resolver, "proc-conflict", DefaultRulePidOn1, node_wide_keeps, normal}, + {on_process_unregistered, syn_test_event_handler_scope_callbacks, LocalNode, scope_without_resolver, "proc-conflict", DefaultRulePidOn1, node_wide_keeps, normal}, + {on_process_registered, syn_test_event_handler_scope_callbacks, LocalNode, scope_without_resolver, "proc-conflict", DefaultRulePidOn2, scope_keeps, normal}, + {on_process_registered, syn_test_event_handler_scope_callbacks, SlaveNode1, scope_without_resolver, "proc-conflict", DefaultRulePidOn1, node_wide_keeps, normal}, + {on_process_registered, syn_test_event_handler_scope_callbacks, SlaveNode2, scope_without_resolver, "proc-conflict", DefaultRulePidOn2, scope_keeps, normal} + ]), + + %% re-join + rpc:call(SlaveNode1, syn_test_suite_helper, connect_node, [SlaveNode2]), + syn_test_suite_helper:assert_cluster(node(), [SlaveNode1, SlaveNode2]), + syn_test_suite_helper:assert_cluster(SlaveNode1, [node(), SlaveNode2]), + syn_test_suite_helper:assert_cluster(SlaveNode2, [node(), SlaveNode1]), + + %% retrieve: the scope resolver, the node-wide resolver and the default rule each keep the process on node 2 + lists:foreach(fun(Node) -> + syn_test_suite_helper:assert_wait( + {PidOn2, {recipient, TestPid, scope_keeps}}, + fun() -> rpc:call(Node, syn, lookup, [scope_with_resolver, "proc-conflict"]) end + ), + syn_test_suite_helper:assert_wait( + {PidOn2, {recipient, TestPid, node_wide_keeps}}, + fun() -> rpc:call(Node, syn, lookup, [scope_without_handler, "proc-conflict"]) end + ), + syn_test_suite_helper:assert_wait( + {DefaultRulePidOn2, {recipient, TestPid, scope_keeps}}, + fun() -> rpc:call(Node, syn, lookup, [scope_without_resolver, "proc-conflict"]) end + ) + end, Nodes), + + %% each resolver runs once on each node that owns a conflicting process, and only in the scope it is selected for; + %% the default rule kills the discarded process, a custom resolver does not (no 'DOWN' for PidOn1) + syn_test_suite_helper:assert_received_messages([ + {resolve_registry_conflict, syn_test_event_handler_scope_resolution, SlaveNode1, scope_with_resolver, "proc-conflict"}, + {resolve_registry_conflict, syn_test_event_handler_scope_resolution, SlaveNode2, scope_with_resolver, "proc-conflict"}, + {resolve_registry_conflict, syn_test_event_handler_node_wide_resolution, SlaveNode1, scope_without_handler, "proc-conflict"}, + {resolve_registry_conflict, syn_test_event_handler_node_wide_resolution, SlaveNode2, scope_without_handler, "proc-conflict"}, + {on_process_unregistered, syn_test_event_handler_scope_callbacks, SlaveNode1, scope_without_resolver, "proc-conflict", DefaultRulePidOn1, node_wide_keeps, syn_conflict_resolution}, + {on_process_registered, syn_test_event_handler_scope_callbacks, SlaveNode1, scope_without_resolver, "proc-conflict", DefaultRulePidOn2, scope_keeps, syn_conflict_resolution}, + {'DOWN', DefaultRulePidOn1MRef, process, DefaultRulePidOn1, {syn_resolve_kill, "proc-conflict", {recipient, TestPid, node_wide_keeps}}} + ]), + true = rpc:call(SlaveNode1, erlang, is_process_alive, [PidOn1]). + +three_nodes_scope_event_handlers_config_boot_sync(Config) -> + %% covers the boot-time sync of a scope that syn starts with a configured handler. A wrong start order (scopes + %% started before the handlers are stored) fails this test only when the sync lands before the handlers are stored, + %% so the test is not a deterministic guard of that order. + + %% get slaves + SlaveNode1 = proplists:get_value(syn_slave_1, Config), + + %% start syn on the local node and register a process + ok = syn:start(), + ok = syn:add_node_to_scopes([scope_with_handler]), + LocalNode = node(), + Pid = syn_test_suite_helper:start_process(), + ok = syn:register(scope_with_handler, "proc-handler", Pid, {recipient, self(), <<"meta">>}), + + %% configure the scope, its own handler and a node-wide handler on node 1 + ok = rpc:call(SlaveNode1, application, set_env, [syn, event_handler, syn_test_event_handler_callbacks]), + ok = rpc:call(SlaveNode1, application, set_env, [syn, scopes, [scope_with_handler]]), + ok = rpc:call(SlaveNode1, application, set_env, [syn, scope_event_handlers, [{scope_with_handler, syn_test_event_handler_scope_callbacks}]]), + + %% start syn on node 1, its scope gets the registration from the local node when it syncs + ok = rpc:call(SlaveNode1, syn, start, []), + + %% check that the callback reaches only the scope handler + syn_test_suite_helper:assert_received_messages([ + {on_process_registered, syn_test_event_handler_scope_callbacks, SlaveNode1, scope_with_handler, "proc-handler", Pid, <<"meta">>, {syn_remote_scope_node_up, scope_with_handler, LocalNode}} + ]). + three_nodes_update(Config) -> %% get slaves SlaveNode1 = proplists:get_value(syn_slave_1, Config), diff --git a/test/syn_test_event_handler_node_wide_resolution.erl b/test/syn_test_event_handler_node_wide_resolution.erl new file mode 100644 index 00000000..0ae0c94e --- /dev/null +++ b/test/syn_test_event_handler_node_wide_resolution.erl @@ -0,0 +1,37 @@ +%% ========================================================================================================== +%% Syn - A global Process Registry and Process Group manager. +%% +%% The MIT License (MIT) +%% +%% Copyright (c) 2015-2026 Roberto Ostinelli and Neato Robotics, Inc. +%% +%% Permission is hereby granted, free of charge, to any person obtaining a copy +%% of this software and associated documentation files (the "Software"), to deal +%% in the Software without restriction, including without limitation the rights +%% to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +%% copies of the Software, and to permit persons to whom the Software is +%% furnished to do so, subject to the following conditions: +%% +%% The above copyright notice and this permission notice shall be included in +%% all copies or substantial portions of the Software. +%% +%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +%% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +%% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +%% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +%% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +%% OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +%% THE SOFTWARE. +%% ========================================================================================================== +-module(syn_test_event_handler_node_wide_resolution). +-behaviour(syn_event_handler). + +-export([resolve_registry_conflict/4]). + +%% keeps the process registered with the marker `node_wide_keeps' and tells the recipient that it was called +resolve_registry_conflict(Scope, Name, {Pid1, {recipient, RecipientPid, Marker1}, _Time1}, {Pid2, _Meta2, _Time2}) -> + RecipientPid ! {resolve_registry_conflict, ?MODULE, node(), Scope, Name}, + case Marker1 of + node_wide_keeps -> Pid1; + _ -> Pid2 + end. diff --git a/test/syn_test_event_handler_scope_callbacks.erl b/test/syn_test_event_handler_scope_callbacks.erl new file mode 100644 index 00000000..2f9c5850 --- /dev/null +++ b/test/syn_test_event_handler_scope_callbacks.erl @@ -0,0 +1,44 @@ +%% ========================================================================================================== +%% Syn - A global Process Registry and Process Group manager. +%% +%% The MIT License (MIT) +%% +%% Copyright (c) 2015-2026 Roberto Ostinelli and Neato Robotics, Inc. +%% +%% Permission is hereby granted, free of charge, to any person obtaining a copy +%% of this software and associated documentation files (the "Software"), to deal +%% in the Software without restriction, including without limitation the rights +%% to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +%% copies of the Software, and to permit persons to whom the Software is +%% furnished to do so, subject to the following conditions: +%% +%% The above copyright notice and this permission notice shall be included in +%% all copies or substantial portions of the Software. +%% +%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +%% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +%% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +%% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +%% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +%% OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +%% THE SOFTWARE. +%% ========================================================================================================== +-module(syn_test_event_handler_scope_callbacks). +-behaviour(syn_event_handler). + +-export([on_process_registered/5]). +-export([on_process_unregistered/5]). +-export([on_process_joined/5]). +-export([on_process_left/5]). + +on_process_registered(Scope, Name, Pid, {recipient, RecipientPid, AdditionalMeta}, Reason) -> + RecipientPid ! {on_process_registered, ?MODULE, node(), Scope, Name, Pid, AdditionalMeta, Reason}. + +on_process_unregistered(Scope, Name, Pid, {recipient, RecipientPid, AdditionalMeta}, Reason) -> + RecipientPid ! {on_process_unregistered, ?MODULE, node(), Scope, Name, Pid, AdditionalMeta, Reason}. + +on_process_joined(Scope, GroupName, Pid, {recipient, RecipientPid, AdditionalMeta}, Reason) -> + RecipientPid ! {on_process_joined, ?MODULE, node(), Scope, GroupName, Pid, AdditionalMeta, Reason}. + +on_process_left(Scope, GroupName, Pid, {recipient, RecipientPid, AdditionalMeta}, Reason) -> + RecipientPid ! {on_process_left, ?MODULE, node(), Scope, GroupName, Pid, AdditionalMeta, Reason}. diff --git a/test/syn_test_event_handler_scope_resolution.erl b/test/syn_test_event_handler_scope_resolution.erl new file mode 100644 index 00000000..3ba9325b --- /dev/null +++ b/test/syn_test_event_handler_scope_resolution.erl @@ -0,0 +1,37 @@ +%% ========================================================================================================== +%% Syn - A global Process Registry and Process Group manager. +%% +%% The MIT License (MIT) +%% +%% Copyright (c) 2015-2026 Roberto Ostinelli and Neato Robotics, Inc. +%% +%% Permission is hereby granted, free of charge, to any person obtaining a copy +%% of this software and associated documentation files (the "Software"), to deal +%% in the Software without restriction, including without limitation the rights +%% to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +%% copies of the Software, and to permit persons to whom the Software is +%% furnished to do so, subject to the following conditions: +%% +%% The above copyright notice and this permission notice shall be included in +%% all copies or substantial portions of the Software. +%% +%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +%% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +%% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +%% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +%% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +%% OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +%% THE SOFTWARE. +%% ========================================================================================================== +-module(syn_test_event_handler_scope_resolution). +-behaviour(syn_event_handler). + +-export([resolve_registry_conflict/4]). + +%% keeps the process registered with the marker `scope_keeps' and tells the recipient that it was called +resolve_registry_conflict(Scope, Name, {Pid1, {recipient, RecipientPid, Marker1}, _Time1}, {Pid2, _Meta2, _Time2}) -> + RecipientPid ! {resolve_registry_conflict, ?MODULE, node(), Scope, Name}, + case Marker1 of + scope_keeps -> Pid1; + _ -> Pid2 + end. diff --git a/test/syn_test_suite_helper.erl b/test/syn_test_suite_helper.erl index 1d7f50ea..f6338bc8 100755 --- a/test/syn_test_suite_helper.erl +++ b/test/syn_test_suite_helper.erl @@ -43,6 +43,7 @@ %% internal -export([process_main/0]). +-export([erase_scope_event_handlers/0]). %% macro -define(DEFAULT_WAIT_TIMEOUT, 5000). @@ -139,6 +140,9 @@ clean_after_test() -> rpc:call(Node, application, unset_env, [syn, event_handler]), rpc:call(Node, application, unset_env, [syn, strict_mode]), rpc:call(Node, application, unset_env, [syn, scopes]), + rpc:call(Node, application, unset_env, [syn, scope_event_handlers]), + %% clean scope event handlers (persistent terms survive the application stop) + rpc:call(Node, syn_test_suite_helper, erase_scope_event_handlers, []), %% messages flush_inbox() end, Nodes). @@ -335,6 +339,12 @@ process_main() -> process_main() end. +erase_scope_event_handlers() -> + lists:foreach(fun + ({{syn_event_handler, _} = Key, _}) -> persistent_term:erase(Key); + (_) -> ok + end, persistent_term:get()). + do_assert_scope_subcluster(Type, Node, Scope, ExpectedNodes) -> do_assert_scope_subcluster(Type, Node, Scope, ExpectedNodes, os:system_time(millisecond)). do_assert_scope_subcluster(Type, Node, Scope, ExpectedNodes, StartAt) ->