Crown

hex.pm VersionBuild StatusLicense

Crown is a leader election and singleton supervision library for Elixir. It runs a child process on exactly one node of an Erlang cluster, delegating leadership authority to a pluggable oracle (database lease, distributed lock, …) so only one node holds the crown at a time, even during netsplits.

When a node holds the crown, Crown starts the configured :child_spec. When it does not, it optionally starts a :follower_child_spec and monitors the leader to claim again as soon as it goes down.

Installation

def deps do
  [
    {:crown, "~> 0.2"},
  ]
end

Built-in Oracles

Postgres Lease

Crown.Oracles.PostgresLease uses a single Postgres table to maintain a time-bounded lease. The leader periodically refreshes the lease before it expires; if the leader dies, the lease expires and another node takes over.

How it works:

The oracle requires an Ecto.Repo. No migrations are needed, the crown_lease_v1 table is automatically created when needed.

children = [
  MyApp.Repo,
  {Crown,
   name: :my_worker,
   oracle: {Crown.Oracles.PostgresLease, repo: MyApp.Repo, duration: 30},
   child_spec: MyApp.SingletonWorker}
]

Supervisor.start_link(children, strategy: :one_for_one)

Oban Peer

Crown.Oracles.ObanPeer follows the leadership maintained by Oban's own peer system. It does not acquire its own lock; instead, Crown becomes leader on whichever node Oban already considers the leader. This is convenient for apps that already run Oban and want to piggyback on its election.

How it works:

children = [
  {Oban, repo: MyApp.Repo, queues: [default: 10]},
  {Crown,
   name: :my_worker,
   oracle: {Crown.Oracles.ObanPeer, oban_name: Oban},
   child_spec: MyApp.SingletonWorker}
]

Supervisor.start_link(children, strategy: :one_for_one)

Custom Oracles

Any module implementing the Crown.Oracle behaviour can be plugged in. The contract is small: init/1, claim/1, refresh/1, and the optional abdicate/1 and handle_info/2.

The claim/1 and refresh/1 callbacks return {true, refresh_delay, state} when leadership is granted, where refresh_delay is the number of milliseconds Crown should wait before calling refresh/1 again (or :infinity for event-driven oracles). They return {false, state} when leadership is denied.

When the oracle options are a keyword list, Crown injects the Crown instance name as :crown_name so the oracle can use it for namespacing (e.g. as a lock key).

defmodule MyApp.HttpLeaseOracle do
  @behaviour Crown.Oracle

  # Talks to an external lock service exposing:
  #   POST   /locks/:name/claim    {"holder": "..."}  -> 200 acquired / 409 held
  #   POST   /locks/:name/refresh  {"holder": "..."}  -> 200 renewed  / 409 lost
  #   DELETE /locks/:name          {"holder": "..."}  -> 204
  # The server is responsible for TTLs and expiry.

  @refresh_delay 5_000

  defstruct [:url, :holder]

  @impl Crown.Oracle
  def init(opts) do
    base = Keyword.fetch!(opts, :base_url)
    name = Atom.to_string(Keyword.fetch!(opts, :crown_name))

    {:ok, %__MODULE__{url: "#{base}/locks/#{name}", holder: Atom.to_string(node())}}
  end

  @impl Crown.Oracle
  def claim(state), do: post(state, "/claim")

  @impl Crown.Oracle
  def refresh(state), do: post(state, "/refresh")

  @impl Crown.Oracle
  def abdicate(state) do
    _ = Req.delete(state.url, json: %{holder: state.holder})
    :ok
  end

  defp post(state, path) do
    case Req.post(state.url <> path, json: %{holder: state.holder}) do
      {:ok, %{status: 200}} -> {true, @refresh_delay, state}
      _ -> {false, state}
    end
  end
end

For oracles that learn about leadership changes through external signals (e.g. a webhook or a pub/sub message) rather than polling, implement handle_info/2 and start Crown with monitor_leader: false. Any message sent to the Crown process will be forwarded to the oracle, which can then return {true, _, state} or {false, state} to drive a claim or release.