lib/cachex/services/overseer.ex

defmodule Cachex.Services.Overseer do
  @moduledoc """
  Service module overseeing the persistence of cache records.

  This module controls the state of caches being handled by Cachex. This was
  originally part of an experiment to see if it was viable to remove a process
  which backed each cache to avoid bottlenecking scenarios and grant the develop
  finer control over their concurrency.

  The result was much higher throughput with better flexibility, and so we kept
  this new design. Cache states are stored in a single ETS table backing this
  module and all cache calls will be routed through here first to ensure their
  state is up to date.
  """
  import Cachex.Errors
  import Cachex.Spec

  # add any aliases
  alias Cachex.Services
  alias Supervisor.Spec

  # add service aliases
  alias Services.Overseer

  # constants for manager/table names
  @manager_name :cachex_overseer_manager
  @table_name :cachex_overseer_table

  ##############
  # Public API #
  ##############

  @doc """
  Creates a new Overseer service tree.

  This will start a basic `Agent` for transactional changes, as well
  as the main ETS table backing this service.
  """
  @spec start_link :: Supervisor.on_start()
  def start_link do
    ets_opts = [read_concurrency: true, write_concurrency: true]
    tab_opts = [@table_name, ets_opts, [quiet: true]]
    mgr_opts = [1, [name: @manager_name]]

    children = [
      %{id: :sleeplocks, start: {:sleeplocks, :start_link, mgr_opts}},
      %{id: Eternal, start: {Eternal, :start_link, tab_opts}, type: :supervisor}
    ]

    Supervisor.start_link(children,
      strategy: :one_for_one,
      name: :cachex_overseer
    )
  end

  @doc """
  Ensures a cache from a name or record.

  Ensuring a cache will map the provided argument to a
  cache record if available, otherwise a nil value.
  """
  @spec ensure(atom | Spec.cache()) :: Spec.cache() | nil
  def ensure(cache() = cache),
    do: cache

  def ensure(name) when is_atom(name),
    do: retrieve(name)

  def ensure(_miss),
    do: nil

  @doc """
  Determines whether a cache is known by the Overseer.
  """
  @spec known?(atom) :: true | false
  def known?(name) when is_atom(name),
    do: :ets.member(@table_name, name)

  @doc """
  Registers a cache record against a name.
  """
  @spec register(atom, Spec.cache()) :: true
  def register(name, cache() = cache) when is_atom(name),
    do: :ets.insert(@table_name, {name, cache})

  @doc """
  Retrieves a cache record, or `nil` if none exists.
  """
  @spec retrieve(atom) :: Spec.cache() | nil
  def retrieve(name) do
    case :ets.lookup(@table_name, name) do
      [{^name, state}] ->
        state

      _other ->
        nil
    end
  end

  @doc """
  Determines whether the Overseer has been started.
  """
  @spec started? :: boolean
  def started?,
    do: Enum.member?(:ets.all(), @table_name)

  @doc """
  Carries out a transaction against the state table.
  """
  @spec transaction(atom, (() -> any)) :: any
  def transaction(name, fun) when is_atom(name) and is_function(fun, 0),
    do: :sleeplocks.execute(@manager_name, fun)

  @doc """
  Unregisters a cache record against a name.
  """
  @spec unregister(atom) :: true
  def unregister(name) when is_atom(name),
    do: :ets.delete(@table_name, name)

  @doc """
  Updates a cache record against a name.

  This is atomic and happens inside a transaction to ensure that we don't get
  out of sync. Hooks are notified of the change, and the new state is returned.
  """
  @spec update(atom, Spec.cache() | (Spec.cache() -> Spec.cache())) ::
          Spec.cache()
  def update(name, fun) when is_atom(name) and is_function(fun, 1) do
    transaction(name, fn ->
      cstate = retrieve(name)
      nstate = fun.(cstate)

      register(name, nstate)

      with hooks(pre: pre_hooks, post: post_hooks) <- cache(nstate, :hooks) do
        pre_hooks
        |> Enum.concat(post_hooks)
        |> Enum.filter(&requires_state?/1)
        |> Enum.map(&hook(&1, :name))
        |> Enum.each(&send(&1, {:cachex_provision, {:cache, nstate}}))
      end

      nstate
    end)
  end

  def update(name, cache(name: name) = cache),
    do: update(name, fn _ -> cache end)

  ##########
  # Macros #
  ##########

  @doc false
  # Enforces a cache binding into a cache record.
  #
  # This will coerce cache names into a cache record, whilst just
  # returning the provided instance if it's already a cache. If
  # the cache cannot be coerced into an instance, a nil value
  # is returned.
  #
  # TODO: this can be optimized further (i.e. at all)
  defmacro enforce(cache, do: body) do
    quote do
      case Overseer.ensure(unquote(cache)) do
        nil ->
          error(:no_cache)

        var!(cache) ->
          cache = var!(cache)

          if :erlang.whereis(cache(cache, :name)) != :undefined do
            unquote(body)
          else
            error(:no_cache)
          end
      end
    end
  end

  ###############
  # Private API #
  ###############

  # Verifies if a hook has a cache provisioned.
  defp requires_state?(hook(module: module)),
    do: :cache in module.provisions()
end