Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Caching expensive computation in elixir

Tags:

elixir

I have an web application in elixir that looks like this

defmodule Test do
  use Plug.Router

  plug :match
  plug :dispatch

  def expensiveComputation() do
     // performs an expensive computation and
     // returns a list
  end

  get "/randomElement" do
    randomElement = expensiveComputation() |> Enum.random
    send_resp(conn, 200, randomElement)
  end

end

Whenever I issue a GET request to /randomElement, expensiveComputation gets called. The expensiveComputation function takes a long time to run but returns the same thing every time it is called. What is the simplest way to cache the result so that it gets run only once on startup?

like image 992
xuanji Avatar asked Feb 05 '16 07:02

xuanji


2 Answers

You could use ETS to cache expensive computations. Here's something I wrote recently, it might not be a full-fledged caching solution but it works just fine for me:

defmodule Cache do
  @table __MODULE__

  def start do
    :ets.new @table, [:named_table, read_concurrency: true]
  end

  def fetch(key, expires_in_seconds, fun) do
    case lookup(key) do
      {:hit, value} ->
        value
      :miss ->
        value = fun.()
        put(key, expires_in_seconds, value)
        value
    end
  end

  defp lookup(key) do
    case :ets.lookup(@table, key) do
      [{^key, expires_at, value}] ->
        case now < expires_at do
          true -> {:hit, value}
          false -> :miss
        end
      _ ->
        :miss
    end
  end

  defp put(key, expires_in_seconds, value) do
    expires_at = now + expires_in_seconds
    :ets.insert(@table, {key, expires_at, value})
  end

  defp now do
    :erlang.system_time(:seconds)
  end
end

First you need to call Cache.start somewhere, so the ETS table will be created (for example in your app's start function). Then you can use it like this:

value = Cache.fetch cache_key, expires_in_seconds, fn ->
  # expensive computation
end

For example:

Enum.each 1..100000, fn _ ->
  message = Cache.fetch :slow_hello_world, 1, fn ->
    :timer.sleep(1000) # expensive computation
    "Hello, world at #{inspect :calendar.local_time}!"
  end
  IO.puts message
end
like image 160
Patrick Oscity Avatar answered Oct 31 '22 21:10

Patrick Oscity


In Elixir when you want state you almost always need to have a process to keep that state. The Agent module is particularly suited for the kind of operation you want - simply wrapping some value and accessing it. Something like this should work:

defmodule Cache do
  def start_link do
    initial_state = expensive_computation
    Agent.start_link(fn -> initial_state end, name: __MODULE__)
  end

  def get(f \\ &(&1)) do
    Agent.get(__MODULE__, f)
  end

  defp expensive_computation do
    # ...
  end
end

Then you can plug in Cache into your supervision tree normally, and just Cache.get when you need the result of expensive_computation.

Please note that this will literally run expensive_computation on startup - in the process bringing up your supervision tree. If it is very expensive - on the order of 10 seconds or more - you might want to move the computation to the Agent process:

def start_link do
  Agent.start_link(fn -> expensive_computation end, name: __MODULE__)
end

You need to handle the case of the cache being empty in that case, while in the first example the startup is blocked until expensive_computation is finished. You can use it by placing workers depending on the Cache later in the startup order.

like image 25
Paweł Obrok Avatar answered Oct 31 '22 22:10

Paweł Obrok