Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions OPTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
57 changes: 56 additions & 1 deletion src/syn.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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]).
Expand Down Expand Up @@ -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:
%%
Expand Down Expand Up @@ -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:
%% <ul>
%% <li>If the Scope has a handler set with this method, Syn uses only that module for all of the Scope's callbacks.
%% If the module does not export a callback, Syn skips that callback for the Scope and does not fall back to the
%% node-wide handler. Without `resolve_registry_conflict/4', Syn applies its default rule: it keeps the later
%% registration and kills the other process.</li>
%% <li>Otherwise Syn uses the node-wide handler, set with {@link set_event_handler/1} or with the `event_handler' key
%% of the `syn' application environment.</li>
%% <li>Without either, Syn uses its defaults.</li>
%% </ul>
%%
%% 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.
%%
%% <h3>Elixir</h3>
%% ```
%% config :syn,
%% scope_event_handlers: [devices: MyDevicesEventHandler]
%% '''
%% <h3>Erlang</h3>
%% ```
%% {syn, [
%% {scope_event_handlers, [{devices, my_devices_event_handler}]}
%% ]}
%% '''
%%
%% <h2>Examples</h2>
%% <h3>Elixir</h3>
%% ```
%% iex> :syn.set_event_handler(:devices, MyDevicesEventHandler)
%% :ok
%% '''
%% <h3>Erlang</h3>
%% ```
%% 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.
%%
Expand Down
2 changes: 2 additions & 0 deletions src/syn_app.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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().

Expand Down
83 changes: 70 additions & 13 deletions src/syn_event_handler.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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:
%% <ul>
%% <li>If the Scope has its own module, Syn uses only that module for all of the Scope's callbacks.
%% If the module does not export a callback, Syn skips that callback for the Scope and does not fall back to the
%% node-wide handler. Without `resolve_registry_conflict/4', Syn applies its default rule: it keeps the later
%% registration and kills the other process.</li>
%% <li>Otherwise Syn uses the node-wide module.</li>
%% <li>Without either, Syn uses its defaults.</li>
%% </ul>
%% 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.
%%
Expand Down Expand Up @@ -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.
%%
%% <h3>Examples</h3>
%% The following callback module implements the `on_process_unregistered/5' and the `on_process_left/5' callbacks.
Expand Down Expand Up @@ -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]).

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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.
38 changes: 36 additions & 2 deletions test/syn_pg_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading