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.utc_now())
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.