Over the past several weeks I’ve been writing about the design and implementation of an Elixir / OTP application. This week, I’m going to step back from the global design and focus on a local design feature for Domain Scrapper by adding a cache to the URL Unshortener using ETS.
Erlang Term Storage
ETS, or Erlang Term Storage, is a key value store for the Erlang runtime system. We can make use of it to build a cache in Elixir.
I have not used ETS before; I have read about how to build a cache in Saša Jurić’s ‘Elixir in Action’. So, given my knowledge but lack of experience I want to do two things while approaching this problem.
- I want to TDD my solution.
- I want to focus first on the interface and integration of the cache into the system and second on the details of ETS.
Since ETS is a key / value store it is similar, in interface, to Elixir’s Map
s. I’m familiar with Map
so I want to start by using it as a stand-in for ETS. I want to get a cache working with good tests and integration into my system using a Map
and then switch my implementation over to ETS.
Writing the tests and designing the interfaces
I think of unit tests as the way to design the interface to my module. Then passing the tests becomes an exercise in writing a working implementation of that interface.
Here are the tests I started with:
defmodule UnshorteningPool.Cache.Test do
use ExUnit.Case
alias UnshorteningPool.Cache
test "It should not hit when empty" do
{:ok, _} = Cache.start_link
assert false == Cache.check("http:///www.example.com")
end
test "It should hit after loading a value" do
Cache.start_link
Cache.add("http:///www.example.com", "http://a-long-url.example.com")
assert "http://a-long-url.example.com" == Cache.check("http:///www.example.com")
end
test "It should be able to change a value" do
Cache.start_link
Cache.add("http:///www.example.com", "http://a-long-url.example.com")
Cache.add("http:///www.example.com", "http://a-different-url.example.com")
assert "http://a-different-url.example.com" == Cache.check("http:///www.example.com")
end
test "it should register itself with the name UnshorteningPool.Cache" do
Cache.start_link
Cache.add("http:///www.example.com", "http://a-long-url.example.com")
assert "http://a-long-url.example.com" == Cache.check("http:///www.example.com")
end
end
These tests describe an interface for a server started with start_link/0
. Once started the server can check
for an existing cache entry or add
a cache entry. add
can either add a new entry or replace an existing one. The server also registers itself with the name UnshorteningPool.Cache.
I chose to use a server to serialize access to the cache itself.
Based on these tests I wrote up this simple GenServer implementation:
defmodule UnshorteningPool.Cache do
use ExActor.GenServer, export: __MODULE__
defstart start_link, do: initial_state(%{})
defcall check(url), state: state, do: reply(state[url] || false)
defcast add(short, long), state: state do
new_state Map.put(state, short, long)
end
end
Using Saša Jurić’s ExActor helps keep our code nice and focused. And we can see that this server is just a simple wrapper over Map
.
start_link/0
set’s the server’s initial state to an empty Map
.
check/1
looks up an entry from the Map
or returns false
if no entry exists.
add/2
is literally a wrapper around Map.put/2
.
This implementation is simple and passes the tests.
Integrating the cache into the unshortener
The next step is to integrate our new cache into the unshortening process. The cache check belongs in the worker module. It is the worker’s responsibility to unshorten and return results. So it can do the new work of checking the cache. Let’s take a look at what’s currently in the worker and then figure out how to add in a cache check.
The worker is simply:
defmodule UnshorteningPool.Worker do
use ExActor.GenServer
defstart start_link(_), do: initial_state(0)
defcast work(url) do
BlockingQueue.push(UnshorteningPool.output_queue,
{self, UnshorteningPool.Unshortener.expand(url)})
new_state(0)
end
end
It is the work/1
cast function that’s important. We can simply add a cache check to it like this:
defcast work(url) do
result = url
|> check_cache(fn url ->
UnshorteningPool.Unshortener.expand(url)
end)
BlockingQueue.push(UnshorteningPool.output_queue, {self, result})
new_state(0)
end
defp check_cache(url, f) do
result = UnshorteningPool.Cache.check(url)
if !result do
result = f.(url)
UnshorteningPool.Cache.add(url, result)
end
result
end
Here we add a check_cache/2
function which takes the cache key (short url) to be checked and a function. The function computes the value (long URL) to store in the cache in the case of a miss. Hit or miss, check_cache/2
returns the long URL.
That said, I didn’t really like this if/then imperative style when I wrote it. I ended up rewriting it this way:
defp check_cache(url, f) do
UnshorteningPool.Cache.check(url) || update_cache(url, f)
end
defp update_cache(url, f) do
result = f.(url)
UnshorteningPool.Cache.add(url, result)
result
end
There was one more step required to integrate the cache into my system. I had to start up the Cache
server. I did this by adding it as a worker in my supervision tree in unshortening_pool.ex:
children = [
# Define workers and child supervisors to be supervised
worker(BlockingQueue, [:infinity, [name: output_queue]]),
worker(UnshorteningPool.Cache, []),
:poolboy.child_spec(pool_name(), poolboy_config(), []),
]
This change actually broke my tests. And it broke them in two different ways. The first, was that each test called Cache.start_link
to create a new test. This would now fail because the cache was already registered and started up with the application. To fix this I simply removed the calls to Cache.start_link
from the tests. This was a satisfying change in that it actually makes the tests more focused. That is, they focus on the things they say they test and none of the tests claim to test start_link
.
The second way that the tests broke is that the first test:
test "It should not hit when empty" do
assert false == Cache.check("http:///www.example.com")
end
continue to fail after removing the call to start_link
. This was due to a race. Because ExUnit runs all my tests in parallel one of the other tests could add the URL “http:///www.example.com” such that this first test would look it up. To fix this I just used a unique URL in this test:
test "It should not hit when empty" do
assert false == Cache.check("http:///not-found.example.com")
end
Arguably, I could change the name of the test as well but I haven’t done that.
With these changes the unit tests pass and when I run the system end-to-end everything works!
Elixir ETS
Only at this stage did I start working with ETS.
This was very straight forward, just replacing Map calls with ETS calls:
defmodule UnshorteningPool.Cache do
use ExActor.GenServer, export: __MODULE__
defstart start_link, do: initial_state(:ets.new(__MODULE__, []))
defcall check(url), state: table do
case :ets.lookup(table, url) do
[{^url, long}] -> reply(long)
[] -> reply(false)
end
end
defcast add(short, long), state: table do
:ets.insert(table, {short, long})
new_state(table)
end
end
The changes are:
start_link/0
, instead of setting state to a new Map
, sets the state to a a new ETS table created with :ets.new/2
. I’ve taken the default options by passing []
. By default the table acts as a set which means a term exists as key only once. Also by default, the access allowed is protected
which means that only the cache server can write to the table. Other processes can read from the table though I don’t make use of this here.
check/1
, instead of looking up an entry from the Map
, looks up an entry from the ETS table. The format of the return value is a little different so we use the case
special form to parse it and conform to the Cache
’s return semantics.
add/2
, instead of wrapping Map.put/2
, calls :ets.insert/2
to insert a new value in the table.
And that’s it. That’s the extent of the changes to switch from a Map
to ETS. I did’t need to change any of the tests or the integration into the system itself. The tests still pass and when I run the system end-to-end it still works.
There are a few things I could choose to do differently here with respect to the access model for the ETS table. First, I could have set the access to private
instead to prevent other processes from reading directly from the table. This would force access to go through the server. Alternatively, I could choose to change my interface and allow workers to read directly from the ETS table. This would save them a round trip of messages through the Cache
server. It should also reduce the load on the Cache
server. This might be a good change to explore if the Cache
server performance needs improvement.
Next Steps
I was very happy with the results of this iteration of the project. The ETS specific part was actually quite small. I’m happy about the way thing worked out with my plan to start by using a Map
and plugin in ETS at the end.
In my next post I’ll continue refining the local design.