Manage plugins while the app runs

v0.1.14

You control plugins while the application runs, with no redeploy. This guide shows how to read a plugin's state, stop it, switch it back on, restart it, remove it, act on every plugin of an event, wait for the event to be rebuilt, and check the health and timing of a plugin chain.

Before you begin

  • Run an application whose plugins are started. See Get Started for a small one.
  • Call these functions from trusted code, such as an admin task or a dashboard action you authorize yourself. They write the plugin registry, so never pass user input to them directly.

Read a plugin's state

The registry holds one record per plugin. Read it with Event.get/2:

elixir
alias MishkaInstaller.Event.Event

Event.get(:name, MyApp.Plugins.SendOtp)   # one record, or nil
Event.get(:event, "after_register")       # every plugin of an event
Event.get(:extension, :my_app)            # every plugin of an application
Event.get()                               # every plugin

A plugin module also has get/0. It returns the keyword list the plugin's process keeps: :name, :event and :status, and :depends once the plugin has registered. Right after the process starts, :status is :starting. The call fails when the process is not running.

Stop a plugin

elixir
MyApp.Plugins.SendOtp.stop()
# {:ok, record}, with record.status == :stopped

The plugin leaves its event's chain when the event is rebuilt, but its process keeps running. The stop survives restarts and dependency changes. Plugins that depend on the stopped plugin are held, across events, and run again when it is enabled. Stopping a plugin that is already stopped returns an error. A plugin that is :held can be stopped.

Switch a plugin back on

elixir
MyApp.Plugins.SendOtp.enable()
# {:ok, record}, with record.status == :restarted

enable/0 is the only call that brings back a stopped plugin. If its dependencies are not all active, the status is :held instead and the call still succeeds. The plugin runs as soon as they are. Plugins that were held for it are released.

Restart a plugin

restart/0 checks the plugin's conditions again and writes :restarted. start/0 does the same with :started. Both refuse a stopped plugin with the message This plugin is stopped, use `enable/2` to switch it back on. Both return an error with the action :hold_statuses when a dependency is not active.

Remove a plugin

elixir
MyApp.Plugins.SendOtp.unregister()

This deletes the record, rebuilds the event, holds the plugins that depended on it, and stops the plugin's process with a normal exit, which its supervisor does not restart. A plugin that is still in a supervision tree registers again on the next boot.

Uninstalling a library that you installed at runtime does not unregister its plugins. Read the documentation on plugins in installed libraries before you remove one.

Act on every plugin of an event

The Event functions take a mode. With :event they act on every plugin of one event and return the plugins that changed:

elixir
Event.stop(:event, "after_register", true)
# {:ok, [records]}

Event.stop(:event, "no_such_event", true)
# {:error, [%{action: :stop_event, field: :global, message: "There are no plugins in the database that can be stopped for this event."}]}

The last argument is queue. The Event reference lists every function.

Wait for the rebuild

Every lifecycle change rebuilds the event's compiled chain. With queue set to true, the default, the rebuild is queued and the call returns before it finishes, so a Hook.call/3 right after may still run the old chain. To return after the rebuild, give the plugin queue: false:

elixir
use MishkaInstaller.Event.Hook, event: "after_register", queue: false

You can also call Event.stop(:name, plugin, false) directly. Do not do this inside a telemetry handler for the library's own events. Those handlers run inside the process that rebuilds events, and a synchronous rebuild would wait on itself. Use true there, as Mishka CMS does. When a rebuild fails, a call with queue set to false returns an error whose action is the name of the function, such as :stop. The new status is already stored by then. The Event reference lists the error under Errors.

Check health

A plugin may define health_check/0. It returns :ok, {:degraded, reason} or {:error, reason}. The default returns :ok.

elixir
@impl true
def health_check() do
  if :persistent_term.get({:health_demo, :service_up}, true),
    do: :ok,
    else: {:error, "notification service down"}
end

This check is from the library's tests. Read the reports with Hook.plugin_health/2 for one plugin, Hook.event_health/2 for one event and Hook.health/1 for every event. A plugin is healthy when its process is alive, its module is callable, its probe returns :ok, and its status is :started or :restarted. An event is healthy when it is compiled, can run, and every plugin is healthy. A probe that takes longer than one second, the default timeout, reports {:error, :timeout}.

Health only reports. An event with an unhealthy plugin still runs its chain.

Measure each plugin

elixir
{:ok, timings} = Hook.profile("login_flow", %{user: 7})
# timings is a list with one entry per plugin, in execution order:
# [%{plugin: MyApp.Plugins.Audit, microseconds: n}, %{plugin: MyApp.Plugins.Notify, microseconds: n}]

Hook.profile/2 runs each plugin of the compiled chain once and reports its time in microseconds, in execution order. It returns {:error, :not_compiled} for an event with no compiled chain. A plugin that raises reports 0, and the next plugin receives the state that the raising one received.

Use it in development, because it does not run the chain the way Hook.call/3 does:

  • It does not stop at a {:reply, :halt, state} reply. It runs every plugin, and the next plugin receives the whole {:reply, :halt, state} tuple as its state, where a real call ends the chain there and returns state.
  • A plugin that defines call/2 receives nil as its second argument.
  • On an event that cannot run, it still lists every plugin of the chain, and a plugin whose module is not loaded reports 0.

Example: stop a dependency

Suppose Mid depends on Root and Leaf depends on Mid, each on its own event:

elixir
{:ok, stopped} = Event.stop(:name, Root, false)
stopped.status                     # :stopped
Event.get(:name, Mid).status       # :held
Event.get(:name, Leaf).status      # :held

{:ok, enabled} = Event.enable(Root, false)
enabled.status                     # :restarted
Event.get(:name, Mid).status       # :restarted
Event.get(:name, Leaf).status      # :restarted

The library's dependency tests check this sequence. A plugin that an operator stopped by hand stays stopped when its dependency comes back.

Next steps