Sheharyar Naseer

Using ETS for Caching in Elixir


The beautiful thing about Elixir (and the Erlang VM in general) is that most of the stuff that requires you to rely on external dependencies and other applications in other languages, can be implemented within your own application in Elixir quite gracefully.

Caching is no different here. Ruby or Java might require you to have something like Redis or Memcache set up, but in Elixir, you can just roll your own quickly using the Erlang Term Storage (ETS).

ETS under a minute

To start using ETS, you first need to create a table:

options = [
  :set,           # Unique by Key
  :public,        # Any process can read or write
  :named_table,   # Identify the table with a custom name/atom
]

:ets.new(:my_little_cache, options)

The above does that with some recommended options. :set is the type of the ets table we’re creating, meaning the entries in the table will be unique by key. :public allows all processes in your elixir app to read and write to the table, and not just the one creating it. :named_table lets us reference the newly created table by the custom :my_little_cache atom for all future calls:

To write to the cache:

:ets.insert(:my_little_cache, {"some-key", "some-value"})
# => true

To read the data back:

:ets.lookup(:my_little_cache, "some-key")
# => [{"some-key", "some-value"}]

Using a wrapper module around ETS

To provide a consistent, simple API to interact with our cache, we can wrap the above in a custom module:

defmodule MyApp.Cache do
  @table :my_little_cache

  @doc """
  Create a new ETS Cache if it doesn't already exists
  """
  def start do
    :ets.new(@table, [:set, :public, :named_table])
    :ok
  rescue
    ArgumentError -> {:error, :already_started}
  end


  @doc """
  Retreive a value back from cache
  """
  def get(key) do
    case :ets.lookup(@table, key) do
      [{^key, value}] -> value
      _else           -> nil
    end
  end


  @doc """
  Put a value into the cache
  """
  def put(key, value) do
    true = :ets.insert(@table, {key, value})
    :ok
  end

end

For the majority of cases this gets the job done quite well. You call Cache.put(key, val) for what you want to cache and Cache.get(key) when you want it back. But another common scenario for me is to cache the result of an existing piece of code, and not run it for subsequent calls, e.g. the response of a common web request. For that, I use this helper that makes it simpler by only caching the result if it succeeds:

@doc """
Runs a piece of code if not already cached
"""
def resolve(key, resolver) when is_function(resolver, 0) do
  case get(key) do
    nil ->
      with {:ok, result} <- resolver.() do
        put(key, result)
        {:ok, result}
      end

    term ->
      {:ok, term}
  end
end

You can call it like this:

with {:ok, plans} <- Cache.resolve(:billing_plans, fn -> BillingAPI.all_plans(key) end) do
  # Do something with plans
end

The first successful call will cache the result and all subsequent calls will retreive it back from the cache instead of making the request every time.

Invalidating Cache

Cached data, depending on the system and the type of data, can go stale. So it makes sense to not rely on it after a certain time period has passed and refetch it. To do that, we can store the timestamps of insertion along with the value in ETS as well:

defmodule MyApp.Cache do
  @table :my_little_cache
  @default_ttl 5 * 60 # 5 minutes

  @doc """
  Create a new ETS Cache if it doesn't already exists
  """
  def start do
    :ets.new(@table, [:set, :public, :named_table])
    :ok
  rescue
    ArgumentError -> {:error, :already_started}
  end


  @doc """
  Retreive a value back from cache
  """
  def get(key, ttl \\ @default_ttl) do
    case :ets.lookup(@table, key) do
      [{^key, value, ts}] ->
        if (timestamp() - ts) <= ttl do
          value
        end

      _else ->
        nil
    end
  end


  @doc """
  Put a value into the cache
  """
  def put(key, value) do
    true = :ets.insert(@table, {key, value, timestamp()})
    :ok
  end


  @doc """
  Delete an entry from the cache
  """
  def delete(key) do
    true = :ets.delete(@table, key)
    :ok
  end


  @doc """
  Runs a piece of code if not already cached
  """
  def resolve(key, ttl \\ @default_ttl, resolver) when is_function(resolver, 0) do
    case get(key, ttl) do
      nil ->
        with {:ok, result} <- resolver.() do
          put(key, result)
          {:ok, result}
        end

      term ->
        {:ok, term}
    end
  end


  # Return current timestamp
  defp timestamp, do: DateTime.to_unix(DateTime.now_utc())
end

You can now pass an optional ttl value (in seconds) after which to ignore the cached result:

# Keep cached for 10 minutes
{:ok, top_users} =
  Cache.resolve(:top_users, 10 * 60, fn ->
    SomeDataService.get_top_users(...)
  end

Concluding Notes

ETS is great if you want to quickly add caching capability in your Elixir app without relying on any external tools or services. While this can take you pretty far, you will begin to encounter issues once you start scaling your app past one node.

Getting ETS to work in a distributed environment where all nodes share the same data is a non-trivial amount of work, and introduces new levels of complexities when building your caching system.

If your use-case is still pretty simple, you can get away by using Mnesia instead of ETS, but for reliable multi-level caching I would suggest going with something like Nebulex that handles all the implementation details for you.