lib/cachex/stats.ex

defmodule Cachex.Stats do
  @moduledoc """
  Hook module to control the gathering of cache statistics.

  This implementation of statistics tracking uses a hook to run asynchronously
  against a cache (so that it doesn't impact those who don't want it). It executes
  as a post hook and provides a solid example of what a hook can/should look like.

  This hook has zero knowledge of the cache it belongs to; it keeps track of an
  internal set of statistics based on the provided messages. This means that it
  can also be mocked easily using raw server calls to `handle_notify/3`.
  """
  use Cachex.Hook

  # need our macros
  import Cachex.Spec
  import Cachex.Errors

  # add our aliases
  alias Cachex.Options

  # update incrementers
  @update_calls [
    :expire,
    :expire_at,
    :persist,
    :refresh,
    :touch,
    :update
  ]

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

  @doc """
  Determines if stats are enabled for a cache.
  """
  @spec enabled?(Spec.cache()) :: boolean
  def enabled?(cache() = cache),
    do: locate(cache) != nil

  @doc """
  Locates a stats hook for a cache, if enabled.
  """
  @spec locate(Spec.cache()) :: Spec.hook() | nil
  def locate(cache(hooks: hooks(post: post_hooks))),
    do: Enum.find(post_hooks, &match?(hook(module: Cachex.Stats), &1))

  @doc """
  Retrieves the latest statistics for a cache.
  """
  @spec retrieve(Spec.cache()) :: %{}
  def retrieve(cache(name: name) = cache) do
    case enabled?(cache) do
      false ->
        error(:stats_disabled)

      true ->
        name
        |> name(:stats)
        |> GenServer.call(:retrieve)
    end
  end

  ####################
  # Server Callbacks #
  ####################

  @doc false
  # Initializes this hook with a new stats container.
  #
  # The `:creationDate` field is set inside the `:meta` field to contain the date
  # at which the statistics container was first created (which is more of less
  # equivalent to the start time of the cache).
  def init(_options),
    do: {:ok, %{meta: %{creation_date: now()}}}

  @doc false
  # Retrieves the current stats container.
  #
  # This will just return the internal state to the calling process.
  def handle_call(:retrieve, _ctx, stats),
    do: {:reply, {:ok, stats}, stats}

  @doc false
  # Registers an action against the stats container.
  #
  # This clause will match against any failed requests and short-circuit to
  # avoid artificially adding errors to the statistics. In future it might
  # be that we want to track this, so this might change at some point.
  #
  # coveralls-ignore-start
  def handle_notify(_action, {:error, _result}, stats),
    do: {:ok, stats}

  # coveralls-ignore-end

  @doc false
  # Registers an action against the stats container.
  #
  # This will increment the call count for every action taken on a cache, as
  # well as incrementing the operation count (although this could be computed
  # from the call counts).
  #
  # It will then pass the statistics on to `register_action/3` in order to
  # allow call specific statistics to be incremented. Note that the order of
  # `register_action/3` is naively ordered to try and optimize for frequency.
  def handle_notify({call, _args} = action, result, stats) do
    stats
    |> increment([:calls, call], 1)
    |> increment([:operations], 1)
    |> register_action(action, result)
    |> wrap(:ok)
  end

  ################
  # Registration #
  ################

  # Handles registration of `get()` command calls.
  #
  # This will increment the hits/misses of the stats container, based on
  # whether the value pulled back is `nil` or not (as `nil` is treated as
  # a missing value through Cachex as of v3).
  defp register_action(stats, {:get, _args}, {_tag, nil}),
    do: increment(stats, [:misses], 1)

  defp register_action(stats, {:get, _args}, {_tag, _value}),
    do: increment(stats, [:hits], 1)

  # Handles registration of `put()` command calls.
  #
  # These calls will just increment the `:writes` count of the statistics
  # container, but only if the write succeeded (as determined by the value).
  defp register_action(stats, {:put, _args}, {_tag, true}),
    do: increment(stats, [:writes], 1)

  # Handles registration of `put_many()` command calls.
  #
  # This is the same as the `put()` handler except that it will count the
  # number of pairs being processed when incrementing the `:writes` key.
  defp register_action(stats, {:put_many, [pairs | _]}, {_tag, true}),
    do: increment(stats, [:writes], length(pairs))

  # Handles registration of `del()` command calls.
  #
  # Cache deletions will increment the `:evictions` key count, based on
  # whether the call succeeded (i.e. the result value is truthy).
  defp register_action(stats, {:del, _args}, {_tag, true}),
    do: increment(stats, [:evictions], 1)

  # Handles registration of `purge()` command calls.
  #
  # A purge call will increment the `:evictions` key using the count of
  # purged keys as the number to increment by. The `:expirations` key
  # will also be incremented in the same way, to surface TTL deletions.
  defp register_action(stats, {:purge, _args}, {_status, count}) do
    stats
    |> increment([:expirations], count)
    |> increment([:evictions], count)
  end

  # Handles registration of `fetch()` command calls.
  #
  # This will delegate through to `register_fetch/2` as the logic is
  # more complicated, and this will keep down the noise of head matches.
  defp register_action(stats, {:fetch, _args}, {label, _value}),
    do: register_fetch(stats, label)

  # Handles registration of `incr()` command calls.
  #
  # This delegates through to `register_increment/4` as the logic is a
  # little more complicated, and this will keep down the noise of matches.
  defp register_action(stats, {:incr, _args} = action, result),
    do: register_increment(stats, action, result, -1)

  # Handles registration of `decr()` command calls.
  #
  # This delegates through to `register_increment/4` as the logic is a
  # little more complicated, and this will keep down the noise of matches.
  defp register_action(stats, {:decr, _args} = action, result),
    do: register_increment(stats, action, result, 1)

  # Handles registration of `update()` command calls.
  #
  # This will increment the `:updates` key if the value signals that the
  # update was successful, otherwise nothing will be modified.
  defp register_action(stats, {:update, _args}, {_tag, true}),
    do: increment(stats, [:updates], 1)

  # Handles registration of `clear()` command calls.
  #
  # This operates in the same way as the `del()` call statistics, except that
  # a count is received in the result, and is used to increment by instead.
  defp register_action(stats, {:clear, _args}, {_tag, count}),
    do: increment(stats, [:evictions], count)

  # Handles registration of `exists?()` command calls.
  #
  # The result boolean will determine whether this increments the `:hits` or
  # `:misses` key of the main statistics container (true/false respectively).
  defp register_action(stats, {:exists?, _args}, {_tag, true}),
    do: increment(stats, [:hits], 1)

  defp register_action(stats, {:exists?, _args}, {_tag, false}),
    do: increment(stats, [:misses], 1)

  # Handles registration of `take()` command calls.
  #
  # Take calls are a little complicated because they need to increment the
  # global eviction count (due to removal) but also increment the global
  # hit/miss count, in addition to the status in the `:take` namespace.
  defp register_action(stats, {:take, _args}, {_tag, nil}),
    do: increment(stats, [:misses], 1)

  defp register_action(stats, {:take, _args}, _result) do
    stats
    |> increment([:hits], 1)
    |> increment([:evictions], 1)
  end

  # Handles registration of `invoke()` command calls.
  #
  # This will increment a custom invocations map to track custom command calls.
  defp register_action(stats, {:invoke, [cmd | _args]}, {:ok, _value}),
    do: increment(stats, [:invocations, cmd], 1)

  # Handles registration of updating command calls.
  #
  # All of the matches calls (dictated by @update_calls) will increment the main
  # `:updates` key in the statistics map only if the value is received as `true`.
  defp register_action(stats, {action, _args}, {_tag, true})
       when action in @update_calls,
       do: increment(stats, [:updates], 1)

  # No-op to avoid crashing on other statistics.
  defp register_action(stats, _action, _result),
    do: stats

  ########################
  # Registration Helpers #
  ########################

  # Handles tracking `fetch()` results based on the result tag.
  #
  # If there's an `:ok`, the value existed and so the `:hits` stat needs to
  # be incremented. If not, we need to increment the `:misses` count. In the
  # case of a miss, we also need to check for `:commit` vs `:ignore` to know
  # whether we should be updating the `:writes` key too.
  defp register_fetch(stats, :ok),
    do: increment(stats, [:hits], 1)

  defp register_fetch(stats, :commit) do
    stats
    |> register_fetch(:ignore)
    |> increment([:writes], 1)
  end

  defp register_fetch(stats, :ignore) do
    stats
    |> increment([:fetches], 1)
    |> increment([:misses], 1)
  end

  # Handles increment calls coming via `incr()` or `decr()`.
  #
  # The logic is the same for both, except for the provided offset (which is
  # basically just a sign flip). It's split out as it's a little more involved
  # than a basic stat count as we need to reverse the arguments to determine if
  # there was a new write or an update (based on the initial/amount arguments).
  defp register_increment(stats, {_type, args}, {_tag, value}, offset) do
    amount = Enum.at(args, 1, 1)
    options = Enum.at(args, 2, [])

    matcher = value + amount * offset

    case Options.get(options, :initial, &is_integer/1, 0) do
      ^matcher ->
        increment(stats, [:writes], 1)

      _anything_else ->
        increment(stats, [:updates], 1)
    end
  end

  ##########################
  # Registration Utilities #
  ##########################

  # Increments statistics in the statistics container.
  #
  # This accepts a list of fields to specify the path to the key to increment,
  # much like the `update_in` provided in more recent versions of Elixir.
  defp increment(stats, [head], amount),
    do: Map.update(stats, head, amount, &(&1 + amount))

  defp increment(stats, [head | tail], amount) do
    Map.put(
      stats,
      head,
      case Map.get(stats, head) do
        nil -> increment(%{}, tail, amount)
        map -> increment(map, tail, amount)
      end
    )
  end
end