Override what a plugin does on start, restart and stop

v0.1.14

Mishka Installer gives every plugin nine functions that you can replace: register/0, start/0, restart/0, stop/0, enable/0, unregister/0, get/0, health_check/0 and on_dependency_error/1. This guide shows how to override one and keep its default with super, which of them the library calls and which only your code calls, how to add work to a start, a stop and a removal, how to report health, and how to react to a failed registration.

Before you begin

  • Have a plugin that runs. The plugin documentation shows how to write one.
  • The samples call MyApp.Sms and MyApp.Alerts. They stand for your own code and are not part of the library.
  • The Hook reference documentation lists the default of each function in one table.

Override a function and keep the default

The use macro defines each of the nine functions and marks it overridable. To replace one, define it in the plugin module with the same name and the same number of arguments. Inside your version, super() runs the default. on_dependency_error/1 takes an argument, so call super(error). Put @impl true above the function, as you do for call/1.

elixir
defmodule MyApp.Plugins.SendOtp do
  use MishkaInstaller.Event.Hook, event: "after_register"

  @impl true
  def call(state), do: {:reply, state}

  @impl true
  def restart() do
    with {:ok, record} <- super() do
      MyApp.Sms.reload_templates()
      {:ok, record}
    end
  end
end

The default of restart/0 reads the stored record, writes :restarted and rebuilds the event. The sample runs it first and does its own step only when the default answered {:ok, record}, then returns that record as it came.

elixir
MyApp.Plugins.SendOtp.restart()
# {:ok, record}, with record.status == :restarted.
# MyApp.Sms.reload_templates() has run.

If the plugin is stopped, the default answers an error, with returns that error, and the step does not run.

Put your code before super() when it must run whatever the default answers. Put it after, inside with, when it belongs to a change that happened. Either way, return what the default returns: {:ok, record} or {:error, errors}. The plugin's process reads the result of register/0 and start/0 in that shape, and the callers of the other functions expect it too.

Know which calls reach an override

The library calls some of the nine functions itself and leaves the rest to your code.

FunctionWho calls it
register/0The plugin's process, when it starts.
start/0The plugin's process, when it starts and register/0 found a :registered plugin, and on its retry. See the next section.
restart/0, stop/0, enable/0, unregister/0, get/0Your code only. The library never calls them.
health_check/0Hook.run_health/2, which Hook.plugin_health/2, Hook.event_health/2 and Hook.health/1 use.
on_dependency_error/1The plugin's process, when register/0 at start returns anything but {:ok, record}.

An override of restart/0, stop/0, enable/0 or unregister/0 runs when your code calls the plugin's own function, such as MyApp.Plugins.SendOtp.stop(). It does not run when the registry changes the plugin another way:

  • Event.start/3, Event.restart/3, Event.stop/3, Event.enable/2 and Event.unregister/3, which a dashboard or a script calls with a plugin's name or with :event for every plugin of an event.
  • The start of every event on boot.
  • The hold and the release of the plugins that depend on a plugin that changed.
  • Event.unregister_gone/1, which the event handler runs each time it starts the events, and which removes the plugins whose code is gone.

A rule that must hold for every way a plugin changes does not belong only in an override. To react whenever an event is rebuilt, whatever caused it, attach a handler to the :compile event, which the telemetry documentation describes.

Run code when a plugin starts

Do the extra work before super() in start/0, so it runs before the default writes the status:

elixir
@impl true
def start() do
  MyApp.Sms.connect()
  super()
end

When a plugin's process starts, it waits for the registry. Once the library has finished its own start, the process calls register/0. If the record it gets back is :registered, the process calls start/0 next. A record that already has another status keeps it, and start/0 does not run. So start/0 runs when a plugin is installed, when its first start was interrupted, and when your code calls it. It does not run on every boot.

If the library has not finished its start when the process wakes, the process tries once more, about one second later. That single retry does not check readiness again, and nothing schedules another. With no record it calls register/0. With a record that the compiled event does not hold, it calls start/0. With a record that the compiled event already holds, it calls neither.

The process reads status and depends from the {:ok, record} that start/0 returns. Any other answer counts as a failed start, and the process keeps the status that register/0 returned. register/0 follows the same pattern: do your work, then return super().

Pause work on stop and resume it on enable

stop/0 writes :stopped. The plugin leaves its event when the event is rebuilt, but its process keeps running, and so does anything the plugin started on its own. A stop is the place to pause that work. enable/0 is the only call that switches a stopped plugin back on, so it is the place to resume it.

elixir
@impl true
def stop() do
  with {:ok, record} <- super() do
    MyApp.Sms.pause()
    {:ok, record}
  end
end

@impl true
def enable() do
  with {:ok, record} <- super() do
    MyApp.Sms.resume()
    {:ok, record}
  end
end

Both versions use with. A second stop() on a stopped plugin returns an error, so the pause does not run twice.

Clean up when a plugin is removed

unregister/0 deletes the record, rebuilds the event, holds the plugins that depend on it and stops the plugin's process with a normal exit. The sample drops the work the plugin still had queued, after the default has succeeded:

elixir
@impl true
def unregister() do
  with {:ok, record} <- super() do
    MyApp.Sms.drop_pending(record.name)
    {:ok, record}
  end
end

The process is gone when super() returns, so get/0 fails after it. Read what you need from the process before super().

An override of unregister/0 does not run when the plugins of a library that you uninstalled are removed with Event.unregister/3. The documentation on plugins in installed libraries shows that cleanup.

Report health

Hook.run_health/2 calls health_check/0 in a short-lived process, so the check never blocks the plugin. The report waits one second by default. The default returns :ok. The sample returns {:degraded, reason} while many messages are waiting, and otherwise keeps the default:

elixir
@impl true
def health_check() do
  case MyApp.Sms.queue_size() do
    size when size > 500 -> {:degraded, "#{size} messages are waiting to be sent"}
    _size -> super()
  end
end

With 600 messages waiting, the reports say so:

elixir
alias MishkaInstaller.Event.Hook

Hook.run_health(MyApp.Plugins.SendOtp)
# {:degraded, "600 messages are waiting to be sent"}

Hook.plugin_health(MyApp.Plugins.SendOtp).healthy?
# false

Return :ok, {:degraded, reason} or {:error, reason}. Any other value is reported as {:error, {:bad_return, value}}, and a check that runs longer than the timeout as {:error, :timeout}. The library does not tell {:degraded, reason} from {:error, reason} when it decides healthy?: both make the plugin and its event unhealthy. Use :degraded for a plugin that still works with a problem.

Health only reports. While a plugin is degraded, its event still runs its chain.

React to a failed registration

on_dependency_error/1 runs when the registration at start fails, although its name mentions dependencies. A dependency cycle and a priority above 100 both end here. It receives the error that register/0 returned. The library logs the error first, then calls the function, then broadcasts :register_error on the event channel. The default returns the error.

elixir
@impl true
def on_dependency_error(error) do
  MyApp.Alerts.notify("#{inspect(__MODULE__)} did not register: #{inspect(error)}")
  super(error)
end
CauseThe error it receives
A priority above 100{:error, [%{field: :priority, action: :max_len, message: "The maximum number the priority field is 100 and you have sent more than this number of entries"}]}
A dependency cycle{:error, [%{field: :depends, action: :register, message: "Dependency cycle detected: MyApp.Plugins.CycleB ultimately depends on itself."}]}

The library ignores what the function returns, and a registration that failed writes nothing. The function runs in the plugin's own process during its start. Keep it short, and send slow work to a task. An exception ends the process with an abnormal exit. Its supervisor starts it again, because the plugin is restart: :transient, and the new process registers and fails again.

Change what get/0 reports

The default returns the state of the plugin's process: :name, :event and :status, and :depends once the plugin has registered. Right after the process starts it is [name: module, event: event, status: :starting], with no :depends. The library never calls get/0. The sample adds the node:

elixir
@impl true
def get(), do: Keyword.put(super(), :node, node())

The default calls the process, so get/0 fails when the process is not running.

What an override must not skip

FunctionIf the default is skipped
register/0Nothing is stored. The plugin's process takes any answer but {:ok, record} for a failed registration: it logs the error, calls on_dependency_error/1 and broadcasts :register_error.
start/0The status stays :registered, the event is not rebuilt and the plugins that wait for this one stay held.
restart/0No status is written and no event is rebuilt.
stop/0The plugin keeps its status and stays in its event, and the plugins that depend on it are not held.
enable/0A stopped plugin stays stopped.
unregister/0The record stays, the event keeps the plugin and its process keeps running.
health_check/0Nothing breaks. The report holds the value you return.
on_dependency_error/1Nothing breaks. The library ignores the return value, and the default only returns the error.
get/0Nothing breaks. The library never calls it.

Next steps