lib/nebulex/adapters/cachex.ex

defmodule Nebulex.Adapters.Cachex do
  @moduledoc """
  Nebulex adapter for [Cachex][Cachex].

  [Cachex]: http://hexdocs.pm/cachex/Cachex.html

  By means of this adapter, you can configure Cachex as the cache backend
  and use it through the Nebulex API.

  ## Options

  Since Nebulex is just a wrapper on top of Cachex, the options are the same as
  [Cachex.start_link/1][cachex_start_link].

  [cachex_start_link]: https://hexdocs.pm/cachex/Cachex.html#start_link/1

  ## Example

  You can define a cache using Cachex as follows:

      defmodule MyApp.Cache do
        use Nebulex.Cache,
          otp_app: :my_app,
          adapter: Nebulex.Adapters.Cachex
      end

  Where the configuration for the cache must be in your application
  environment, usually defined in your `config/config.exs`:

      config :my_app, MyApp.Cache,
        limit: 1_000_000,
        stats: true,
        ...

  If your application was generated with a supervisor (by passing `--sup`
  to `mix new`) you will have a `lib/my_app/application.ex` file containing
  the application start callback that defines and starts your supervisor.
  You just need to edit the `start/2` function to start the cache as a
  supervisor on your application's supervisor:

      def start(_type, _args) do
        children = [
          {MyApp.Cache, []},
        ]

        ...
      end

  Since Cachex uses macros for some configuration options, you could also
  pass the options in runtime when the cache is started, either by calling
  `MyApp.Cache.start_link/1` directly, or in your app supervision tree:

      def start(_type, _args) do
        children = [
          {MyApp.Cache, cachex_opts()},
        ]

        ...
      end

      defp cachex_opts do
        import Cachex.Spec

        [
          expiration: expiration(
            # default record expiration
            default: :timer.seconds(60),

            # how often cleanup should occur
            interval: :timer.seconds(30),

            # whether to enable lazy checking
            lazy: true
          ),

          # complex limit
          limit: limit(
            size: 500,
            policy: Cachex.Policy.LRW,
            reclaim: 0.5,
            options: []
          ),

          ...
        ]
      end

  > See [Cachex.start_link/1][cachex_start_link] for more information.

  ## Telemetry events

  This adapter emits the recommended Telemetry events.
  See the "Telemetry events" section in `Nebulex.Cache`
  for more information.

  ## Distributed caching topologies

  In the same way we use the distributed adapters and the multilevel one to
  create distributed topologies, we can also do the same but instead of using
  the built-in local adapter using Cachex.

  For example, let's define a multi-level cache (near cache topology), where
  the L1 is a local cache using Cachex and the L2 is a partitioned cache.

      defmodule MyApp.NearCache do
        use Nebulex.Cache,
          otp_app: :nebulex,
          adapter: Nebulex.Adapters.Multilevel

        defmodule L1 do
          use Nebulex.Cache,
            otp_app: :nebulex,
            adapter: Nebulex.Adapters.Cachex
        end

        defmodule L2 do
          use Nebulex.Cache,
            otp_app: :nebulex,
            adapter: Nebulex.Adapters.Partitioned,
            primary_storage_adapter: Nebulex.Adapters.Cachex
        end
      end

  And the configuration may look like:

      config :my_app, MyApp.NearCache,
        model: :inclusive,
        levels: [
          {MyApp.NearCache.L1, [limit: 100_000]},
          {MyApp.NearCache.L2, primary: [limit: 1_000_000]}
        ]

  > **NOTE:** You could also use [NebulexRedisAdapter][nbx_redis_adapter] for
    L2, it would be matter of changing the adapter for the L2 and the
    configuration to set up Redis adapter.

  [nbx_redis_adapter]: https://github.com/cabol/nebulex_redis_adapter

  See [Nebulex examples](https://github.com/cabol/nebulex_examples). You will
  find examples for all different topologies, even using other adapters like
  Redis; for all examples you can just replace `Nebulex.Adapters.Local` by
  `Nebulex.Adapters.Cachex`.
  """

  # Provide Cache Implementation
  @behaviour Nebulex.Adapter
  @behaviour Nebulex.Adapter.Entry
  @behaviour Nebulex.Adapter.Queryable
  @behaviour Nebulex.Adapter.Persistence
  @behaviour Nebulex.Adapter.Stats

  # Inherit default transaction implementation
  use Nebulex.Adapter.Transaction

  import Nebulex.Adapter
  import Nebulex.Helpers

  alias Cachex.{Options, Query}
  alias Nebulex.Entry

  @compile {:inline, to_ttl: 1}

  ## Nebulex.Adapter

  @impl true
  defmacro __before_compile__(_), do: :ok

  @impl true
  def init(opts) do
    name =
      normalize_module_name([
        opts[:name] || Keyword.fetch!(opts, :cache),
        Cachex
      ])

    adapter_meta = %{
      name: name,
      telemetry: Keyword.fetch!(opts, :telemetry),
      telemetry_prefix: Keyword.fetch!(opts, :telemetry_prefix),
      stats: Options.get(opts, :stats, &is_boolean/1, false)
    }

    child_spec =
      opts
      |> Keyword.put(:name, name)
      |> Keyword.put(:stats, adapter_meta.stats)
      |> Cachex.child_spec()

    {:ok, child_spec, adapter_meta}
  end

  ## Nebulex.Adapter.Entry

  @impl true
  defspan get(adapter_meta, key, _opts) do
    Cachex.get!(adapter_meta.name, key)
  end

  @impl true
  defspan get_all(adapter_meta, keys, _opts) do
    Enum.reduce(keys, %{}, fn key, acc ->
      if value = Cachex.get!(adapter_meta.name, key) do
        Map.put(acc, key, value)
      else
        acc
      end
    end)
  end

  @impl true
  defspan put(adapter_meta, key, value, ttl, on_write, _opts) do
    do_put(adapter_meta.name, key, value, ttl, on_write)
  end

  defp do_put(name, key, value, ttl, :put) do
    Cachex.put!(name, key, value, ttl: to_ttl(ttl))
  end

  defp do_put(name, key, value, ttl, :replace) do
    Cachex.update!(name, key, value, ttl: to_ttl(ttl))
  end

  defp do_put(name, key, value, ttl, :put_new) do
    # FIXME: This is a workaround since Cachex does not support a direct action
    #        for put_new. Fix it if a better solution comes up.
    if Cachex.get!(name, key) do
      false
    else
      Cachex.put!(name, key, value, ttl: to_ttl(ttl))
    end
  end

  @impl true
  defspan put_all(adapter_meta, entries, ttl, on_write, _opts) do
    do_put_all(adapter_meta.name, entries, ttl, on_write)
  end

  defp do_put_all(name, entries, ttl, on_write) when is_map(entries) do
    do_put_all(name, :maps.to_list(entries), ttl, on_write)
  end

  defp do_put_all(name, entries, ttl, :put) when is_list(entries) do
    Cachex.put_many!(name, entries, ttl: to_ttl(ttl))
  end

  defp do_put_all(name, entries, ttl, :put_new) when is_list(entries) do
    {keys, _} = Enum.unzip(entries)

    # FIXME: This is a workaround since Cachex does not support a direct action
    #        for put_new. Fix it if a better solution comes up.
    Cachex.transaction!(name, keys, fn worker ->
      if Enum.any?(keys, &(worker |> Cachex.exists?(&1) |> elem(1))) do
        false
      else
        Cachex.put_many!(worker, entries, ttl: to_ttl(ttl))
      end
    end)
  end

  @impl true
  defspan delete(adapter_meta, key, _opts) do
    true = Cachex.del!(adapter_meta.name, key)
    :ok
  end

  @impl true
  defspan take(adapter_meta, key, _opts) do
    Cachex.take!(adapter_meta.name, key)
  end

  @impl true
  defspan has_key?(adapter_meta, key) do
    {:ok, bool} = Cachex.exists?(adapter_meta.name, key)
    bool
  end

  @impl true
  defspan ttl(adapter_meta, key) do
    cond do
      # Key does exist and has a TTL associated with it
      ttl = Cachex.ttl!(adapter_meta.name, key) ->
        ttl

      # Key does exist and hasn't a TTL associated with it
      Cachex.get!(adapter_meta.name, key) ->
        :infinity

      # Key does not exist
      true ->
        nil
    end
  end

  @impl true
  defspan expire(adapter_meta, key, ttl) do
    Cachex.expire!(adapter_meta.name, key, to_ttl(ttl))
  end

  @impl true
  defspan touch(adapter_meta, key) do
    Cachex.touch!(adapter_meta.name, key)
  end

  @impl true
  defspan update_counter(adapter_meta, key, amount, ttl, default, _opts) do
    do_update_counter(adapter_meta.name, key, amount, ttl, default)
  end

  defp do_update_counter(name, key, amount, :infinity, default) do
    Cachex.incr!(name, key, amount, initial: default)
  end

  defp do_update_counter(name, key, incr, ttl, default) do
    # FIXME: This is a workaround since Cachex does not support `:ttl` here.
    #        Fix it if a better solution comes up.
    Cachex.transaction!(name, [key], fn worker ->
      counter = Cachex.incr!(worker, key, incr, initial: default)
      if ttl = to_ttl(ttl), do: Cachex.expire!(worker, key, ttl)
      counter
    end)
  end

  ## Nebulex.Adapter.Queryable

  @impl true
  defspan execute(adapter_meta, operation, query, _opts) do
    do_execute(adapter_meta.name, operation, query)
  end

  defp do_execute(name, :count_all, nil) do
    Cachex.size!(name)
  end

  defp do_execute(name, :delete_all, nil) do
    Cachex.clear!(name)
  end

  defp do_execute(name, :delete_all, :expired) do
    Cachex.purge!(name)
  end

  defp do_execute(name, :all, query) do
    name
    |> do_stream(query, [])
    |> Enum.to_list()
  end

  defp do_execute(_name, operation, query) do
    raise Nebulex.QueryError, message: "unsupported #{operation}", query: query
  end

  @impl true
  defspan stream(adapter_meta, query, opts) do
    do_stream(adapter_meta.name, query, opts)
  end

  defp do_stream(name, nil, opts) do
    do_stream(name, Query.create(true, :key), opts)
  end

  defp do_stream(name, query, opts) do
    query = maybe_return_entry(query, opts[:return])
    Cachex.stream!(name, query, batch_size: opts[:page_size] || 20)
  rescue
    e in Cachex.ExecutionError ->
      reraise Nebulex.QueryError, [message: e.message, query: query], __STACKTRACE__
  end

  defp maybe_return_entry([{pattern, conds, _ret}], :key) do
    [{pattern, conds, [:"$1"]}]
  end

  defp maybe_return_entry([{pattern, conds, _ret}], :value) do
    [{pattern, conds, [:"$4"]}]
  end

  defp maybe_return_entry([{pattern, conds, _ret}], {:key, :value}) do
    [{pattern, conds, [{{:"$1", :"$4"}}]}]
  end

  defp maybe_return_entry([{pattern, conds, _ret}], :entry) do
    [{pattern, conds, [%Entry{key: :"$1", value: :"$4", touched: :"$2", ttl: :"$3"}]}]
  end

  defp maybe_return_entry(query, _return), do: query

  ## Nebulex.Adapter.Persistence

  @impl true
  defspan dump(adapter_meta, path, opts) do
    case Cachex.dump(adapter_meta.name, path, opts) do
      {:ok, true} -> :ok
      {:error, _} = error -> error
    end
  end

  @impl true
  defspan load(adapter_meta, path, opts) do
    case Cachex.load(adapter_meta.name, path, opts) do
      {:ok, true} -> :ok
      {:error, _} = error -> error
    end
  end

  ## Nebulex.Adapter.Stats

  @impl true
  defspan stats(adapter_meta) do
    if adapter_meta.stats do
      {meta, stats} =
        adapter_meta.name
        |> Cachex.stats!()
        |> Map.pop(:meta, %{})

      %Nebulex.Stats{
        measurements: stats,
        metadata: meta
      }
    end
  end

  ## Private Functions

  defp to_ttl(:infinity), do: nil
  defp to_ttl(ttl), do: ttl
end