Sheharyar Naseer

Better Caching in Elixir with Nebulex


In my last post I wrote about using ETS for caching in your Elixir app, and while ETS does remain a popular approach to in-memory caching for simple, one-off applications, it will eventually lead you down a rabbit hole of confusing bugs and unexpected results once you begin scaling your app beyond a single node.

This doesn’t mean that ETS can’t work in distributed scenarios at all, it can. But the legwork required and the possibility of not implementing things the right way™️, is very high and that is something you don’t want to do when you’re scaling your app. The general consensus is to use :mnesia in ram-only mode (check out Memento!), but even then it’s up to the developer to handle some implementation details (like network partitions).

Nebulex

If it were not for Nebulex, I would probably be writing a post about implementing distributed caches using Mnesia/Memento. But it exists, and it implements a system of multi-level caches in a distributed system supporting a wide range of scenarios and custom strategies, while still giving excellent reliability and support out of the box. For an in-depth guide to Nebulex, I suggest this excellent article on Erlang Battleground – But in this post, I’ll only cover how to get quickly started with Nebulex for a multi-node application.

We’ll be defining a 2-level cache, so let’s add configs for both of them:

# config/config.exs

config :my_app, MyApp.Cache.Local,
  gc_interval: 86_400

config :my_app, MyApp.Cache.Distributed,
  local: MyApp.Cache.Local,
  node_selector: Nebulex.Adapters.Dist

Once we have that out of the way, we can define our Cache module:

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

  defmodule Distributed do
    use Nebulex.Cache, otp_app: :my_app, adapter: Nebulex.Adapters.Dist
  end
end

Caching Helpers

I like to go a step further and add some methods to hide the internal API calls, as well as some helpers from my previous post, to make the Cache interface much more pleasant to use:

defmodule MyApp.Cache do
  @default_ttl 5 * 60


  # Cache Levels
  # ------------


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

  defmodule Distributed do
    use Nebulex.Cache, otp_app: :my_app, adapter: Nebulex.Adapters.Dist
  end


  # Public API
  # ----------


  @doc """
  Takes a resolver function whose value is only cached if it
  returns an `{:ok, any()}` tuple
  """
  def resolve(type, key, opts \\ [], resolver) when is_function(resolver, 0) do
    Distributed.transaction(fn ->
      case get(type, key) do
        nil ->
          with {:ok, result} <- resolver.() do
            {:ok, set(type, key, result, opts)}
          end

        result ->
          {:ok, result}
      end
    end)
  end


  @doc "Get an item from the cache"
  def get(type, key), do: Distributed.get(name(type, key))


  @doc "Put an item in the cache"
  def set(type, key, value, opts \\ []) do
    name = name(type, key)
    opts = Keyword.put_new(opts, :ttl, @default_ttl)

    Distributed.set(name, value, opts)
  end


  @doc "Delete an item from the cache"
  def delete(type, key), do: Distributed.delete(name(type, key))


  @doc "Clear all cached items"
  defdelegate flush, to: Distributed


  # Private Helpers
  # ---------------


  defp name(type, key), do: "#{type}:#{key}"
end

get/2 and set/4 are pretty straightforward:

# Put in cache with a timeout of 10 minutes
{:ok, result} = XYZ.expensive_process(user, args)
Cache.set(:an_expensive_call, user.id, result, ttl: 10 * 60)

# Get it back
Cache.get(:an_expensive_call, user.id)

But most of the time you want to cache the result of a piece of code immediately, and not run it at all if it’s still valid in the cache. That’s where the resolve/4 method above comes in. Suppose you often have to make a web request to fetch a company’s list of users from Slack which might change once every few days:

def fetch_users_from_slack(company) do
  Cache.resolve(:slack_users, [ttl: 60 * 60], company.id, fn ->
    with {:ok, response} <- SlackAPI.get("users.list", company.token)
      {:ok, response["users"]}
    end
  end)
end

You could accomplish a lot more with the resolve/4 helper, easily caching different parts of your application. Combined with Nebulex’s multi-level caching framework in distributed applications, this makes the whole process a breeze.