lib/absinthe_cache.ex

defmodule AbsintheCacheFairy do
  @moduledoc ~s"""
  Provides the macro `cache_resolve` that replaces the Absinthe's `resolve` and
  caches the result of the resolver for some time instead of calculating it
  every time.
  """

  alias __MODULE__, as: CacheMod
  alias AbsintheCacheFairy.ConCacheProvider, as: CacheProvider

  require Logger

  @ttl 300
  @max_ttl_offset 120

  # TODO: Make it configurable
  @cache_name :graphql_cache

  @compile {:inline,
            wrap: 2,
            wrap: 3,
            from: 2,
            resolver: 3,
            store: 2,
            store: 3,
            get_or_store: 2,
            get_or_store: 3,
            cache_modify_middleware: 3,
            cache_key: 2,
            convert_values: 2,
            generate_additional_args: 1}

  @doc ~s"""
  Macro that's used instead of Absinthe's `resolve`. This resolver can perform
  the following operations:
  1. Get the value from a cache if it is persisted. The resolver function is not
  evaluated at all in this case
  2. Evaluate the resolver function and store the value in the cache if it is
  not present there
  3. Handle the `Absinthe.Middlewar.Async` and `Absinthe.Middleware.Dataloader`
  middlewares. In order to handle them, the function that executes the actual
  evaluation is wrapped in a function that handles the cache interactions

  There are 2 options for the passed function:
  1. It can be a captured named function because its name is extracted
  and used in the cache key.
  2. If the function is anonymous or a different name should be used, a second
  parameter with that name must be passed.

  Just like `resolve` comming from Absinthe, `cache_resolve` supports the `{:ok, value}`
  and `{:error, reason}` result tuples. The `:ok` tuples are cached while the `:error`
  tuples are not.

  But `cache_resolve` knows how to handle a third type of response format. When
  `{:nocache, {:ok, value}}` is returned as the result the cache does **not** cache
  the value and just returns `{:ok, value}`. This is particularly useful when
  the result can't be constructed but returning an error will crash the whole query.
  In such cases a default/filling value can be passed (0, nil, "No data", etc.)
  and the next query will try to resolve it again
  """

  defmacro cache_resolve(captured_mfa_ast, opts \\ []) do
    quote do
      middleware(
        Absinthe.Resolution,
        CacheMod.from(unquote(captured_mfa_ast), unquote(opts))
      )
    end
  end

  @doc ~s"""
  Exposed as sometimes it can be useful to use it outside the macros.

  Gets a function, name and arguments and returns a new function that:
  1. On execution checks if the value is present in the cache and returns it
  2. If it's not in the cache it gets executed and the value is stored in the cache.

  NOTE: `cached_func` is a function with arity 0. That means if you want to use it
  in your code and you want some arguments you should use it like this:
    > Cache.wrap(
    >   fn ->
    >     fetch_last_price_record(pair)
    >   end,
    >   :fetch_price_last_record, %{pair: pair}
    > ).()
  """
  def wrap(cached_func, name, args \\ %{}, opts \\ []) do
    fn ->
      CacheProvider.get_or_store(
        @cache_name,
        cache_key(name, args, opts),
        cached_func,
        &cache_modify_middleware/3
      )
    end
  end

  @doc ~s"""
  Clears the whole cache. Slow.
  """
  def clear_all() do
    CacheProvider.clear_all(@cache_name)
  end

  @doc ~s"""
  The size of the cache in megabytes
  """
  def size() do
    CacheProvider.size(@cache_name, :megabytes)
  end

  def get(key) do
    CacheProvider.get(@cache_name, key)
  end

  @doc false
  def from(captured_mfa, opts) when is_function(captured_mfa) do
    # Public so it can be used by the resolve macros. You should not use it.
    case Keyword.pop(opts, :fun_name) do
      {nil, opts} ->
        fun_name = captured_mfa |> :erlang.fun_info() |> Keyword.get(:name)
        resolver(captured_mfa, fun_name, opts)

      {fun_name, opts} ->
        resolver(captured_mfa, fun_name, opts)
    end
  end

  # Private functions

  defp resolver(resolver_fn, name, opts) do
    fn
      %{} = root, args, resolution ->
        fun = fn -> resolver_fn.(root, args, resolution) end

        # by default use only :id from the root
        root_keys = Keyword.get(opts, :root_keys, [:id])
        additional_args = Map.take(root, root_keys)

        # resolution.source contains the arguments passed to a parent object
        # in order to properly cache timeseries data in the query
        # {getMetric(metric: "nvt") {timeseriesData(...)}}
        # the key must include `metric` from the parent's args
        args_from_source = generate_additional_args(resolution.source)

        cache_key = cache_key({name, additional_args, args_from_source}, args, opts)

        # In some edge-cases the caching can be disabled for some reason. In one
        # particular case for all_projects_by_function the caching is disabled
        # (by putting the do_not_cache_query: true Process dictionary key-value)
        # if the base_projects depends on a watchlist. The cache resolver that
        # is disabled must provide the `honor_do_no_cache_flag: true` explicitly,
        # so we are not disabling all of the caching, but only the one that matters
        skip_cache? =
          Keyword.get(opts, :honor_do_not_cache_flag, false) and
            Process.get(:do_not_cache_query) == true

        case skip_cache? do
          true -> fun.()
          false -> get_or_store(cache_key, fun)
        end
    end
  end

  defp generate_additional_args(data) do
    case data do
      %{id: id} -> id
      %{slug: slug} -> slug
      %{word: word} -> word
      _ -> data
    end
  end

  def store(cache_name \\ @cache_name, cache_key, value) do
    CacheProvider.store(cache_name, cache_key, value)
  end

  def get_or_store(cache_name \\ @cache_name, cache_key, resolver_fn) do
    CacheProvider.get_or_store(
      cache_name,
      cache_key,
      resolver_fn,
      &cache_modify_middleware/3
    )
  end

  # `cache_modify_middleware` is called only from withing `get_or_store` that
  # guarantees that it will be executed only once if it is accessed concurently.
  # This is way it is safe to use `store` explicitly without worrying about race
  # conditions
  defp cache_modify_middleware(cache_name, cache_key, {:ok, value} = result) do
    CacheProvider.store(cache_name, cache_key, result)

    {:ok, value}
  end

  defp cache_modify_middleware(
         cache_name,
         cache_key,
         {:middleware, Absinthe.Middleware.Async = midl, {fun, opts}}
       ) do
    caching_fun = fn ->
      CacheProvider.get_or_store(cache_name, cache_key, fun, &cache_modify_middleware/3)
    end

    {:middleware, midl, {caching_fun, opts}}
  end

  defp cache_modify_middleware(
         cache_name,
         cache_key,
         {:middleware, Absinthe.Middleware.Dataloader = midl, {loader, callback}}
       ) do
    caching_callback = fn loader_arg ->
      CacheProvider.get_or_store(
        cache_name,
        cache_key,
        fn -> callback.(loader_arg) end,
        &cache_modify_middleware/3
      )
    end

    {:middleware, midl, {loader, caching_callback}}
  end

  # Helper functions

  def cache_key(name, args, opts \\ []) do
    base_ttl = args[:caching_params][:base_ttl] || Keyword.get(opts, :ttl, @ttl)

    max_ttl_offset =
      args[:caching_params][:max_ttl_offset] ||
        Keyword.get(opts, :max_ttl_offset, @max_ttl_offset)

    base_ttl = Enum.max([base_ttl, 1])
    max_ttl_offset = Enum.max([max_ttl_offset, 1])

    # Used to randomize the TTL for lists of objects like list of projects
    additional_args = Map.take(args, [:slug, :id])

    # Using phash2 as a random number between 0 and max_ttl_offset is needed.
    # collisions are allowed and do not lead to errors
    ttl = base_ttl + ({name, additional_args} |> :erlang.phash2(max_ttl_offset))

    if args[:caching_params] do
      # This is used in the Absinthe's before_send function
      Process.put(:__change_absinthe_before_send_caching_ttl__, ttl)
    end

    args = args |> convert_values(ttl)
    cache_key = [name, args] |> hash()

    {cache_key, ttl}
  end

  # Convert the values for using in the cache. A special treatement is done for
  # `%DateTime{}` so all datetimes in a @ttl sized window are treated the same
  defp convert_values(%DateTime{} = v, ttl), do: div(DateTime.to_unix(v, :second), ttl)
  defp convert_values(%_{} = v, _), do: Map.from_struct(v)

  defp convert_values(args, ttl) when is_list(args) or is_map(args) do
    args
    |> Enum.map(fn
      {k, v} ->
        [k, convert_values(v, ttl)]

      data ->
        convert_values(data, ttl)
    end)
  end

  defp convert_values(v, _), do: v

  defp hash(data) do
    :crypto.hash(:sha256, :erlang.term_to_binary(data))
    |> Base.encode64()
  end
end