Skip to content

Add per-Scope event handlers - #93

Open
vheathen wants to merge 2 commits into
ostinelli:masterfrom
vheathen:scope-event-handlers
Open

vheathen wants to merge 2 commits into
ostinelli:masterfrom
vheathen:scope-event-handlers

Conversation

@vheathen

@vheathen vheathen commented Oct 5, 2026

Copy link
Copy Markdown

Closes #91.

Two libraries in one VM that each bring their own syn_event_handler module cannot both have their callbacks run today: syn has one handler per node, and the last set_event_handler/1 call wins. syn:set_event_handler(Scope, Module) sets the handler of one Scope, and the scope_event_handlers key, a list of {Scope, Module} tuples, does the same for the Scopes that syn starts itself from the scopes key.

Before: one module, chosen for the whole node, runs every callback of every Scope. After: a Scope with its own module uses only that module; a Scope without one uses the node-wide handler from the event_handler key, and syn's defaults when there is none. set_event_handler/1 and the event_handler key are unchanged.

The handler is chosen per Scope, not per callback. If the Scope's 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. The edoc of resolve_registry_conflict/4 now says that every node must resolve a conflict in a Scope the same way, which in practice means the same module and version.

The configuration key exists because the scope processes started from scopes exist before host code can call set_event_handler/2, so their first events, the sync with other nodes included, would otherwise go to the node-wide handler or to the defaults. syn fails to start when the key is malformed or names a module that cannot be loaded: starting anyway would leave that Scope without callbacks and with default conflict resolution.

Per-Scope handlers are stored in persistent_term, one key {syn_event_handler, Scope} per Scope. The handler is read on every callback and written rarely, at startup; a persistent_term read is constant time and does not copy the term to the heap. One key per Scope means two concurrent set_event_handler/2 calls cannot lose each other's entry, as a read-modify-write of one list in the application environment could. It also works before syn starts and survives a restart of the application. The cost the persistent_term docs name, a global garbage collection in which all processes scan their heaps when a term is replaced or erased, does not apply here: the stored value is a module name, and deletion of atoms is specially optimized to avoid that GC.

It may make sense to move the node-wide event_handler to persistent_term as well. This pull request leaves it in the application environment so that set_event_handler/1 and the event_handler key behave exactly as before. The price is on the fallback path. Measured on OTP 27 in a running node with 293 applications: reading the handler from the application environment takes 32 ns, a persistent_term hit 17 ns, and a Scope without its own handler (a persistent_term miss, then the environment) 41 ns. With the node-wide handler in persistent_term too, that fallback would take 23 ns.

OTP: persistent_term:get/2 exists since OTP 21.3. syn declares no minimum OTP version; the code so far needed 21.0 (logger and Class:Reason:Stacktrace), and CI tested 21.3 until cf2b318 in August 2024. CI now runs 27 and 28.

Both suites pass on OTP 25.3.2.8, 27.3.4.11 and 29.0.6 (registry 26 cases, pg 14). Dialyzer is clean on 27.3.4.11 and 29.0.6.

Two commits: the function and the selection, then the configuration key. I can squash them if you prefer one.

Several libraries in one VM can each own their syn scopes, but syn has
one event handler per node. A library that needs its own conflict
resolution or callbacks has to chain to the handler set before it, or
overwrite it and break the other library.

syn:set_event_handler/2 sets a handler for a single scope. syn selects
the handler for a scope as a whole, not per callback. A scope with its
own handler uses only that module: a callback the module does not
export behaves as if no handler were set, which for
resolve_registry_conflict/4 means the default rule (keep the later
registration, kill the other process with syn_resolve_kill), and the
node-wide handler is not called for that scope. A scope without its
own handler uses the node-wide handler from the event_handler key, and
syn's defaults when there is none.

The scope handler lives in persistent_term, so it can be set before syn
starts and before the scope is added. A later call for the same scope
replaces it.
syn starts the scopes listed under the scopes key inside its own boot,
in syn_sup:init/1. When syn starts as a dependency, those scope
processes exist before host code can call syn:set_event_handler/2, so
their first events, including the sync with other nodes, go to the
node-wide handler or to syn's defaults.

The scope_event_handlers key takes a list of {Scope, Module} tuples.
syn_app:start/2 applies every entry before it starts the supervisor.
In interactive mode it loads each module first, as set_event_handler/2
does; in embedded mode the release has already loaded it. The list is
applied at every start of the application and replaces a handler set
earlier for the same scope with set_event_handler/2. Handlers of other
scopes stay as they are.

The application fails to start when:

- an entry is not a tuple of two atoms, or has undefined as the
  module: a function_clause shows the entry;
- the value is not a list: a function_clause shows the value;
- an entry names a module that cannot be loaded: a
  {scope_event_handler_not_loaded, Scope, Module} error shows the
  scope and the module. Starting anyway would leave the scope without
  callbacks and with syn's default conflict resolution.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-Scope event handlers

1 participant