How events and plugins work
v0.1.14
Mishka Installer turns each event into a small module that it builds at runtime and that calls the event's plugins one after another. This page explains what an event is, which parts the library keeps, how a call reaches the plugins, what happens when the set of plugins changes, and what happens when a plugin's code is missing. Read it before you design the events of an application.
What an event is
An event is a string at a place in your code where you allow other code to act, such as after_register. Without events you change the core source each time a feature is added: send an SMS, add a login provider, fill a page's SEO tags. With an event, the core calls Hook.call("after_register", data) once, and anyone can attach a plugin to that name from outside the core.
The plugins of an event form a chain. Each plugin receives the result of the one before it, and the result of the last one is the result of the call.
The parts
- Plugin. A module that calls
use MishkaInstaller.Event.Hook. It is also aGenServerwhose only job is to register the plugin and keep a copy of its status. - Registry. One Mnesia table of plugin records,
MishkaInstaller.Event.Event. A record holds the plugin module, its event, its priority, its status, the OTP application it belongs to, its dependencies and its extra data. - Compiled event module. A module named
MishkaInstaller.Event.ModuleStateCompiler.State.<Event>. It holds the chain of one event. - Event handler. A process that builds the compiled modules, one at a time.
How a call reaches the plugins
Hook.call/3 turns the event name into a module name, which it remembers in :persistent_term. If that module exists, it calls the module. The module calls each plugin's call function directly, in the process that made the call. A call sends no message to a plugin process and reads no database table.
The module is built from the plugin list. The library writes the chain as nested case expressions: {:reply, state} goes on to the next plugin and {:reply, :halt, state} returns. After the chain, the module applies the private and return arguments.
The module name comes from the event name. The library trims the name, replaces spaces with underscores, removes leading digits and camelizes the rest. Two names that reduce to the same text share one module, so spell each event one way. The name becomes an atom, so use names written in your code and never names taken from user input.
How the chain changes
A lifecycle change, such as starting, stopping or unregistering a plugin, first writes the registry and then asks the event handler to rebuild the event:
- The handler reads the event's plugins and keeps those that are not stopped or held and whose dependencies are all active.
- It orders them so that a plugin comes after its dependencies, and breaks ties by priority and then by name.
- It builds a new module only if the list changed, or if the event started or stopped being able to run. The old version stays callable until the new one loads.
Rebuilds run one at a time, so event modules are never created and removed at once. By default a lifecycle call queues the rebuild and returns before it finishes. The compiled module lives in memory on one node. On boot each node builds its own from the replicated table, and a rebuild on one node is announced to the others.
The library compiles each module from a quoted expression with Code.compile_quoted/2, and two details of that build reach beyond the event. Each build sets the compiler option ignore_module_conflict to true and leaves it set. The option belongs to the whole node, so from the first build on, code that you compile at runtime no longer gets a warning when it redefines a module. Each build also gives the compiler a random file name of eight uppercase letters and digits, so module_info(:compile) of an event module reports that name as its source, not a file of yours.
When plugins cannot run
A plugin can be started in the registry while its module is not loaded, for example while its library is still on the way in. The handler then builds a stub for that event. The stub runs no plugin of the event. It answers the cannot_run argument, or the data when there is none, and the library logs which plugins it is waiting for. The module's mode/0 returns :error instead of :ok.
If the module is gone for good, because a newer version of its application dropped or renamed the plugin, the library unregisters it. The event handler does this each time it starts every event, which includes the boot, once the libraries installed at runtime are loaded. It happens only when the application is loaded, does not list the plugin's module, and the module is not loaded either. A plugin whose application is not loaded is left as it is. Whatever depended on an unregistered plugin is held.
At no other time does anything check. A library that you upgrade at runtime keeps its old modules loaded until the next boot, and one that you uninstall is no longer loaded, so the check finds neither. Read the documentation on plugins in installed libraries.
What happens on boot
- The Mnesia store starts, and creates its tables on the first boot.
- The install handler loads the libraries that were installed at runtime, so their plugin modules exist.
- The event handler unregisters plugins whose code is gone, then starts every event: stopped plugins stay stopped, and a plugin whose dependencies are not active yet is held until they are.
- The log shows
[mishka_installer.event] plugins synchronized.
The event handler runs step 3 again, and logs the line again, each time the Mnesia store announces that it is ready again, for example when the store process restarts or another node of the cluster announces its own store. The documentation on PubSub messages and readiness lists the messages behind these steps and shows how to tell from your own code that the library is ready.
Plugin processes register as they start. A plugin that starts before the steps above finish tries once more about one second later. The retry does not check whether the library is ready, and nothing schedules another. With no stored record the plugin registers, with a record that the compiled event does not hold it starts, and otherwise it only reads its stored status. If the registration fails, the plugin logs failed to register and stays unregistered. The documentation on overriding plugin functions shows how to add your own work to the registration and the start.
Example: events at the seams of a CMS
Mishka CMS fires an event after every action on a resource, named from the resource and the action. A plugin listens on the event instead of changing the resource. This plugin tells the people who manage permissions that a user's permissions changed:
use MishkaInstaller.Event.Hook,
event: PluginHook.event_name(MishkaCmsCore.Accounts.User, :assign_permissions),
initial: %{priority: 40}
@impl true
def call(user, meta) do
# ... notify the holders of admin:dashboard:permission ...
{:reply, user}
endThe event is cms_core:user:after_assign_permissions. The notifier passes the saved record as the data and a read-only map as meta, %{actor: actor, site_id: site_id}.