AgentMap

The AgentMap can be seen as a stateful Map that parallelize operations made on different keys.

For instance, this call:

iex> fun =
...>   fn v ->
...>     :timer.sleep(10)
...>     {:_get, v + 1}
...>   end
...>
iex> map = Map.new(a: 1, b: 1)
iex> {:_get, map} = Map.get_and_update(map, :a, fun)
iex> {:_get, map} = Map.get_and_update(map, :b, fun)
iex> Map.get(map, :a)
2
iex> Map.get(map, :b)
2

will be executed in 20 ms, while this:

iex> fun =
...>   fn v ->
...>     :timer.sleep(10)
...>     {:_get, v + 1}
...>   end
...>
iex> am = AgentMap.new(a: 1, b: 1)
iex> AgentMap.get_and_update(am, :a, fun)
:_get
iex> AgentMap.get_and_update(am, :b, fun)
iex> AgentMap.get(am, :a)
2
iex> AgentMap.get(am, :b)
2

in around of 10 ms, because of parallelization.

Underneath it’s a GenServer that holds a Map. When a state changing call is first made for a key (update/4, update!/4, get_and_update/4, …), a special temporary process called “worker” is spawned. All subsequent calls for that key will be forwarded to the message queue of this worker. This process respects the order of incoming new calls, executing them in a sequence, except for get/4 calls, which are processed as a parallel Tasks. For each key, the degree of parallelization can be tweaked using max_processes/3 function. The worker will die after about 10 ms of inactivity.

The AgentMap supports multi-key calls — operations made on a group of keys. See AgentMap.Multi.

Basically, AgentMap can be used as a cache, memoization, computational framework and, sometimes, as a GenServer replacement.

See documentation for AgentMap.

Examples

Create and use it as an ordinary Map:

iex> am = AgentMap.new(a: 42, b: 24)
iex> AgentMap.get(am, :a)
42
iex> AgentMap.keys(am)
[:a, :b]
iex> am
...> |> AgentMap.update(:a, & &1 + 1)
...> |> AgentMap.update(:b, & &1 - 1)
...> |> AgentMap.take([:a, :b])
%{a: 43, b: 23}

The special struct %AgentMap{} can be created via the new/1 function. This allows to use the Enumerable protocol.

Also, AgentMap can be started in an Agent manner:

iex> {:ok, pid} = AgentMap.start_link()
iex> pid
...> |> AgentMap.put(:a, 1)
...> |> AgentMap.get(:a)
1
iex> pid
...> |> AgentMap.new()
...> |> Enum.empty?()
false

More complicated example involves memoization:

defmodule Calc do
  def fib(0), do: 0
  def fib(1), do: 1
  def fib(n) when n >= 0 do
    unless GenServer.whereis(__MODULE__) do
      AgentMap.start_link([], name: __MODULE__)
      fib(n)
    else
      AgentMap.get_and_update(__MODULE__, n, fn
        nil ->
          # This calculation will be made in a separate
          # worker process.
          res = fib(n - 1) + fib(n - 2)
          # Return `res` and set it as a new value.
          {res, res}

        _value ->
          # Change nothing, return current value.
          :id
      end)
    end
  end
end

Take a look at the test/memo.ex.

The AgentMap provides possibility to make multi-key calls (operations on multiple keys). Let’s see an accounting demo:

defmodule Account do
  def start_link() do
    AgentMap.start_link([], name: __MODULE__)
  end

  def stop() do
    AgentMap.stop(__MODULE__)
  end

  @doc """
  Returns `{:ok, balance}` or `:error` in case there is no
  such account.
  """
  def balance(account) do
    AgentMap.fetch(__MODULE__, account)
  end

  @doc """
  Withdraws money. Returns `{:ok, new_amount}` or `:error`.
  """
  def withdraw(account, amount) do
    AgentMap.get_and_update(__MODULE__, account, fn
      nil ->     # no such account
        {:error} # (!) refrain from returning `{:error, nil}`
                 # as it would create key with `nil` value

      balance when balance > amount ->
        balance = balance - amount
        {{:ok, balance}, balance}

      _balance ->
        # Returns `:error`, while not changing value.
        {:error}
    end)
  end

  @doc """
  Deposits money. Returns `{:ok, new_amount}` or `:error`.
  """
  def deposit(account, amount) do
    AgentMap.get_and_update(__MODULE__, account, fn
      nil ->
        {:error}

      balance ->
        balance = balance + amount
        {{:ok, balance}, balance}
    end)
  end

  @doc """
  Trasfers money. Returns `:ok` or `:error`.
  """
  def transfer(from, to, amount) do
    # Multi call.
    AgentMap.Multi.get_and_update(__MODULE__, [from, to], fn
      [nil, _] -> {:error}

      [_, nil] -> {:error}

      [b1, b2] when b1 >= amount ->
        {:ok, [b1 - amount, b2 + amount]}

      _ -> {:error}
    end)
  end

  @doc """
  Closes account. Returns `:ok` or `:error`.
  """
  def close(account) do
    AgentMap.pop(__MODULE__, account) && :ok || :error
  end

  @doc """
  Opens account. Returns `:ok` or `:error`.
  """
  def open(account) do
    AgentMap.get_and_update(__MODULE__, account, fn
      nil ->
        # Sets balance to 0, while returning :ok.
        {:ok, 0}

      _balance ->
        # Returns :error, while not changing balance.
        {:error}
    end)
  end
end

Installation

AgentMap requires Elixir v1.8 Add :agent_mapto your list of dependencies in mix.exs:

def deps do
    [{:agent_map, "~> 1.0"}]
end

License

MIT.