ETS caching in Elixir

Erlang Term Storage and all of its related functions come from Erlang and can be accessed under its atom namespace.

:ets
We'll start by creating an ETS cache table. You can follow along in an Elixir shell.

$ iex
  iex> :ets.new(:table, [:set, :protected])

Okay, so let's break this down. We call the new/2 function to create a new cache table, naming it ":table" to keep things simple. The options :set and :protected can be omitted, as they are default. However, with this setup we can't easily access our cache table, so let's fix that.

iex> :ets.new(:table, [:public, :named_table])

By default we are still setting our table type to :set, meaning our data will be stored as one value per unique key. We've changed access control to :public, so that other processes can access our table. And we've added the :named_table option to allow us to easily access our table by name.

Now that our table is setup let's insert some data! We can use the insert/2 function to store data. The only requirement is that it must be a tuple whose first value is the key.

iex> :ets.insert(:table, {:key, "value"})

Now we have an ets cache table (:table) with one piece of data, its key being :key and a string value: "value". This example is simple, but you can imagine in production having something like a user id for a key and the user object its value.

To lookup this data, we simply call lookup/2 with our table name and key.

iex> :ets.lookup(:table, :key)
iex> [key: "value"]

Neat! But why did we get a list of one tuple? Remember we get the :set option by default. But there are actually four other types of ETS tables, including :bag, which allows inserts of many objects that can share the same unique key. So it is possible to get multiple values for the same key, if we want. I encourage you to explore the other types of ETS tables.

In addition to lookup/2 there is the match_object/2 function which allows us to (sort of) get a list of everything in our cache table. Let's insert some more data to help explain.

iex> :ets.insert(:table, {:key, "overwriting old value"})
iex> :ets.insert(:table, {:a, "a value...so clever"})
iex> :ets.insert(:table, {:b, "is for", "boring"})

Now that we've inserted some extra data into our table, let's get a list of all the things.

:ets.match_object(:table, {:"$1", :"$2"})
[a: "a value...so clever", key: "overwriting old value"]

What is this wizardry? And where is my third piece of data!?

Well, match_object/2 does exactly that, it returns any data that matches on the items in the tuple. In our case we used the wildcards :"$1" and :"$2" to match on any two item tuples. Since our third piece of data is a three part tuple, it doesn't match and is not returned in the list.

So let's get rid of our boring three part friend. We can delete a record from our cache with the delete/2 function.

iex> :ets.delete(:table, :b)

There are a lot more functions for ETS tables, but these are certainly the most common. A few others worth noting are i/1 which can be helpful in the console and allow you to peek into the cache table without having to know exactly what kind of tuple to match on.

iex> :ets.i(:table)
<1> {key,<<"overwriting old value">>}
<2> {a,<<"a value...so clever">>}

And the all/0 function gives you a list of all the ets tables running in your app, which can be useful when trying to remember the name of your ets table.

That's all for now. In our next post we will discuss how we use ETS at Versus Systems and look at an example where we handle thousands of user requests with concurrent reads.