Leaderboard with ETS

I’m trying to create a leaderboard with a ETS table.

defmodule Leaderboard do
  use GenServer

  def start_link do
    GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  end

  def get_top_users(limit) do
    GenServer.call(__MODULE__, {:top, limit})
  end

  def insert(user_id, points) do
    GenServer.call(__MODULE__, {:insert, user_id, points})
  end

  def update(user_id, new_points) do
    GenServer.call(__MODULE__, {:update, user_id, new_points})
  end

  def delete(user_id) do
    GenServer.call(__MODULE__, {:delete, user_id})
  end


  def init(_) do
    :ets.new(:leaderboard, [:ordered_set, :named_table, :protected])
    {:ok, nil}
  end

  def handle_call({:top, limit}, _, state) do
    {:reply, :ets.select(:leaderboard, [{{:"_", :"_"}, [], [:"$_"]}], limit), state}
  end

  def handle_call({:update, user_id, new_points}, _, state) do
    {:reply, :ets.update_element(:leaderboard, user_id, {2, new_points}), state}
  end

  def handle_call({:insert, user_id, points}, _, state) do
    {:reply, :ets.insert(:leaderboard, {user_id, points}), state}
  end

  def handle_call({:delete, user_id}, _, state) do
    {:reply, :ets.delete(:leaderboard, user_id), state}
  end
end

And even though I am using :ordered_set, the response from :ets.select is not ordered by the points count.

iex(2)> Leaderboard.start_link
{:ok, #PID<0.222.0>}
iex(3)> Leaderboard.insert 1, 0
true
iex(4)> Leaderboard.insert 2, 0
true
iex(5)> Leaderboard.update 2, 100
true
iex(6)> Leaderboard.get_top_users 10
{[{1, 0}, {2, 100}], :"$end_of_table"}

Am I doing something wrong in the :ets.select function? Or have I misunderstood what :ordered_set means?

EDIT: It seems like ordered set orders after the key not the value. This stackoverflow thread was helpful, and this is what I’ve ended up with (adapted from Pascal’s answer):

defmodule Leaderboard do
  use GenServer

  def start_link do
    GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  end

  def update(user_id, score) do
    GenServer.call(__MODULE__, {:update, user_id, score})
  end

  def delete(user_id) do
    GenServer.call(__MODULE__, {:delete, user_id})
  end

  def get(limit) do
    GenServer.call(__MODULE__, {:get, limit})
  end


  def init(_) do
    :ets.new(:leaderboard_score, [:ordered_set, :private, :named_table])
    :ets.new(:leaderboard_user, [:set, :private, :named_table])
    {:ok, nil}
  end

  def handle_call({:get, limit}, _, state) when is_integer(limit) and limit > 0 do
    best = limit |> do_get() |> :lists.reverse
    {:reply, {:ok, best}, state}
  end

  def handle_call({:update, user_id, score}, _, state) do
    do_update(user_id, score)
    {:reply, :ok, state}
  end

  def handle_call({:delete, user_id}, _, state) do
    do_delete(user_id)
    {:reply, :ok, state}
  end


  defp do_update(user_id, score) do
    case :ets.lookup(:leaderboard_user, user_id) do
      [] ->
        :ok
      [{^user_id, old_score}] ->
        :ets.delete(:leaderboard_score, {old_score, user_id})
    end

    :ets.insert(:leaderboard_score, {{score, user_id}})
    :ets.insert(:leaderboard_user, {user_id, score})
  end

   defp do_delete(user_id) do
    case :ets.lookup(:leaderboard_user, user_id) do
      [] ->
        :ok
      [{^user_id, score}] ->
        :ets.delete(:leaderboard_score, {score, user_id})
        :ets.delete(:leaderboard_user, user_id)
    end
  end

  defp do_get(limit) do
    last = :ets.last(:leaderboard_score)
    do_get(limit - 1, last, [])
  end

  defp do_get(_n, :"$end_of_table", acc), do: acc
  defp do_get(0, {score, user_id}, acc), do: [{user_id ,score} | acc]
  defp do_get(n, {score, user_id} = last, acc) do
    prev = :ets.prev(:leaderboard_score, last)
    do_get(n - 1, prev, [{user_id, score} | acc])
  end
end
6 Likes