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