cx_leaderboard v0.1.0 CxLeaderboard.Leaderboard View Source
Leaderboard is a lightweight database designed to optimize storing and sorting data based on ranked scores. It has the following abilities:
- Create any number of leaderboards
- Store scores and payloads
- Calculate ranks, percentiles, and other stats
- Use custom ranking, percentile, and other stat functions
- Provide a sorted stream of all records
- Support custom tie-breakers for records of the same rank
- Provide a range of records around a specific id (contextual leaderboard)
- Add/remove/update/upsert individual records in an existing leaderboard
- Re-populate the leaderboard with asynchrony and atomicity
- Build mini-leaderboards contained in simple elixir structs
- Add your own custom storage engine (
CxLeaderboard.Storage
behaviour)
Here’s a quick example. We use the negative score values trick to make higher score sort to the top naturally.
alias CxLeaderboard.Leaderboard
my_lb = Leaderboard.create!(name: :main)
my_lb =
Leaderboard.populate!(my_lb, [
{{-50, :id1}, :alice},
{{-40, :id2}, :bob}
])
my_lb
|> Leaderboard.top()
|> Enum.take(2)
# [
# {{-50, :id1}, :alice, {0, {1, 99.0}}},
# {{-40, :id2}, :bob, {1, {2, 50.0}}}
# ]
Entry
An entry is a structure that you populate into the leaderboard. The shape of an entry can be one of the following:
{score, id}
{score, tiebreaker, id}
{{score, id}, payload}
{{score, tiebreaker, id}, payload}
A score
can be any term — it will be used for sorting and ranking.
A tiebreaker
(also any term) comes in handy when you know that you will have
multiple records of the same rank, and you’d like to use additional criteria
to sort them in the leaderboard.
An id
is any term that uniquely identifies a record, and that you will be
using to get
them. Id is always the last tiebreaker.
A payload
is any term that you’d like to store with your record. Use it for
everything you need to display the leaderboard. If not provided, id
will be
used as the payload.
Record
A record is what you get back when querying the leaderboard. It contains both your entry, and calculated stats. Here’s what it looks like:
# without tiebreaker
{{score, id}, payload, {index, {rank, percentile}}}
#\___key___/ \payload/ \entry stats/ \___rank stats___/
# with tiebreaker
{{score, tiebreaker, id}, payload, {index, {rank, percentile}}}
#\_________key_________/ \payload/ \entry stats/ \___rank stats___/
Stats
By default the stats you get are index, rank, and percentile. However, passing
a custom indexer into the create/1
or client_for/2
functions allows you to
calculate your own stats. To learn more about indexer customization read the
module docs of CxLeaderboard.Indexer
.
Link to this section Summary
Functions
Adds a single entry to an existing leaderboard. Invalid entries will return an error. If the id already exists, will return an error
Same as add/2
but returns the leaderboard or raises
Updates an entry in an existing leaderboard, or adds it if the id doesn’t exist. Invalid entries will return an error
Same as add_or_update/2
but returns the leaderboard or raises
Populates a leaderboard with entries asynchronously. Only works with
CxLeaderboard.EtsStore
. Invalid entries are silently skipped
Same as async_populate/2
but returns the leaderboard or raises
Returns a stream of bottom leaderboard records
Clears the data from a leaderboard
Same as clear/1
but returns the leaderboard or raises
When your leaderboard is started as a server elsewhere, use this function to
get a reference to be able to interact with it. See docs for start_link/2
for more information on client/server mode of operation
Returns the number of records in a leaderboard. This number is stored in the leaderboard, so this is an O(1) operation
Creates a new leaderboard
Same as create/1
but returns the leaderboard or raises an error
Retrieves a single record from a leaderboard by id. Returns nil
if record is
not found
Retrieves a range of records from a leaderboard around the given id. Returns an empty list if the requested record is not found. If the range goes out of leaderboard bounds will stop at the top/bottom without error. If the given range is in reverse direction, returns entries in reverse direction as well
Populates a leaderboard with entries replacing any existing content. Invalid entries are silently skipped
Same as populate/2
but returns the leaderboard or raises
Removes an entry from a leaderboard by id. If the id is not in the leaderboard, will return an error
Same as remove/2
but returns the leaderboard or raises
If your chosen storage engine supports server/client operation
(CxLeaderboard.EtsStore
does), then you could set Leaderboard
as a worker
in your application’s children list. For each leaderboard you would just add a
worker, passing it a name. Then in your applicaiton you can use client_for/2
to get the reference to it that you can use to call all the functions in this
module
Returns a stream of top leaderboard records
Updates a single entry in an existing leaderboard. Invalid entries will return an error. If the id is not in the leaderboard, will return an error
Same as update/2
but returns the leaderboard or raises
Link to this section Types
t() :: %CxLeaderboard.Leaderboard{ data: Enumerable.t() | nil, indexer: CxLeaderboard.Indexer.t(), state: state(), store: module() }
Link to this section Functions
add(CxLeaderboard.Leaderboard.t(), CxLeaderboard.Entry.t()) :: {:ok, CxLeaderboard.Leaderboard.t()} | {:error, term()}
Adds a single entry to an existing leaderboard. Invalid entries will return an error. If the id already exists, will return an error.
See Entry section of the module doc for information about entries.
Examples
iex> {:ok, board} = Leaderboard.create(name: :foo)
iex> {:ok, board} = Leaderboard.add(board, {-1, :id1})
iex> Leaderboard.count(board)
1
iex> Leaderboard.add(board, :invalid_entry)
{:error, :bad_entry}
iex> Leaderboard.add(board, {-1, :id1})
{:error, :entry_already_exists}
Same as add/2
but returns the leaderboard or raises.
add_or_update(CxLeaderboard.Leaderboard.t(), CxLeaderboard.Entry.t()) :: {:ok, CxLeaderboard.Leaderboard.t()} | {:error, term()}
Updates an entry in an existing leaderboard, or adds it if the id doesn’t exist. Invalid entries will return an error.
See Entry section of the module doc for information about entries.
Examples
iex> {:ok, board} = Leaderboard.create(name: :foo)
iex> {:ok, board} = Leaderboard.add_or_update(board, {1, :id1})
iex> Leaderboard.get(board, :id1)
{{1, :id1}, :id1, {0, {1, 99.0}}}
iex> {:ok, board} = Leaderboard.add_or_update(board, {2, :id1})
iex> Leaderboard.get(board, :id1)
{{2, :id1}, :id1, {0, {1, 99.0}}}
add_or_update!(CxLeaderboard.Leaderboard.t(), CxLeaderboard.Entry.t()) :: CxLeaderboard.Leaderboard.t()
Same as add_or_update/2
but returns the leaderboard or raises.
async_populate(CxLeaderboard.Leaderboard.t(), Enumerable.t()) :: {:ok, CxLeaderboard.Leaderboard.t()} | {:error, term()}
Populates a leaderboard with entries asynchronously. Only works with
CxLeaderboard.EtsStore
. Invalid entries are silently skipped.
See Entry section of the module doc for information about entries.
Examples
iex> {:ok, board} = Leaderboard.create(name: :foo)
iex> {:ok, board} = Leaderboard.async_populate(board, [
...> {-2, :id1},
...> {-3, :id2}
...> ])
iex> Leaderboard.count(board)
0
iex> :timer.sleep(100)
iex> Leaderboard.count(board)
2
async_populate!(CxLeaderboard.Leaderboard.t(), Enumerable.t()) :: CxLeaderboard.Leaderboard.t()
Same as async_populate/2
but returns the leaderboard or raises.
bottom(CxLeaderboard.Leaderboard.t()) :: Enumerable.t()
Returns a stream of bottom leaderboard records.
Examples
iex> {:ok, board} = Leaderboard.create(name: :foo)
iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}])
iex> Leaderboard.bottom(board) |> Enum.take(1)
[{{-2, :id1}, :id1, {1, {2, 50.0}}}]
clear(CxLeaderboard.Leaderboard.t()) :: {:ok, CxLeaderboard.Leaderboard.t()} | {:error, term()}
Clears the data from a leaderboard.
Examples
iex> {:ok, board} = Leaderboard.create(name: :foo)
iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}])
iex> Leaderboard.count(board)
2
iex> {:ok, board} = Leaderboard.clear(board)
iex> Leaderboard.count(board)
0
clear!(CxLeaderboard.Leaderboard.t()) :: CxLeaderboard.Leaderboard.t()
Same as clear/1
but returns the leaderboard or raises.
client_for(atom(), module()) :: CxLeaderboard.Leaderboard.t()
When your leaderboard is started as a server elsewhere, use this function to
get a reference to be able to interact with it. See docs for start_link/2
for more information on client/server mode of operation.
count(CxLeaderboard.Leaderboard.t()) :: non_neg_integer()
Returns the number of records in a leaderboard. This number is stored in the leaderboard, so this is an O(1) operation.
Examples
iex> {:ok, board} = Leaderboard.create(name: :foo)
iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}])
iex> Leaderboard.count(board)
2
create(keyword()) :: {:ok, CxLeaderboard.Leaderboard.t()} | {:error, term()}
Creates a new leaderboard.
Options
:store
- storage engine to use for the leaderboard. SupportsCxLeaderboard.EtsStore
andCxLeaderboard.TermStore
. Default:CxLeaderboard.EtsStore
.:indexer
- indexer to use for stats calculation. The default indexer calculates rank with offsets (e.g. 1,1,3) and percentile based on same-or- lower scores, within 1-99 range. Learn more about making custom indexers inCxLeaderboard.Indexer
module doc.:name
- sets the name identifying the leaderboard. Only needed when usingCxLeaderboard.EtsStore
.
Examples
iex> Leaderboard.create(name: :global)
{:ok,
%Leaderboard{
state: :global,
store: CxLeaderboard.EtsStore,
indexer: %CxLeaderboard.Indexer{},
data: []
}
}
create!(keyword()) :: CxLeaderboard.Leaderboard.t()
Same as create/1
but returns the leaderboard or raises an error.
Retrieves a single record from a leaderboard by id. Returns nil
if record is
not found.
Examples
iex> {:ok, board} = Leaderboard.create(name: :foo)
iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}])
iex> Leaderboard.get(board, :id1)
{{-2, :id1}, :id1, {1, {2, 50.0}}}
iex> Leaderboard.get(board, :missing_id)
nil
get(CxLeaderboard.Leaderboard.t(), CxLeaderboard.Entry.id(), Range.t()) :: [ CxLeaderboard.Record.t() ]
Retrieves a range of records from a leaderboard around the given id. Returns an empty list if the requested record is not found. If the range goes out of leaderboard bounds will stop at the top/bottom without error. If the given range is in reverse direction, returns entries in reverse direction as well.
Examples
iex> {:ok, board} = Leaderboard.create(name: :foo)
iex> {:ok, board} = Leaderboard.populate(board, [
...> {-4, :id1},
...> {-3, :id2},
...> {-2, :id3},
...> {-1, :id4}
...> ])
iex> Leaderboard.get(board, :id3, -1..0)
[
{{-3, :id2}, :id2, {1, {2, 74.5}}},
{{-2, :id3}, :id3, {2, {3, 50.0}}}
]
iex> Leaderboard.get(board, :id3, 0..-1)
[
{{-2, :id3}, :id3, {2, {3, 50.0}}},
{{-3, :id2}, :id2, {1, {2, 74.5}}}
]
populate(CxLeaderboard.Leaderboard.t(), Enumerable.t()) :: {:ok, CxLeaderboard.Leaderboard.t()} | {:error, term()}
Populates a leaderboard with entries replacing any existing content. Invalid entries are silently skipped.
See Entry section of the module doc for information about entries.
Examples
iex> {:ok, board} = Leaderboard.create(name: :foo)
iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}])
iex> Leaderboard.count(board)
2
populate!(CxLeaderboard.Leaderboard.t(), Enumerable.t()) :: CxLeaderboard.Leaderboard.t()
Same as populate/2
but returns the leaderboard or raises.
remove(CxLeaderboard.Leaderboard.t(), CxLeaderboard.Entry.id()) :: {:ok, CxLeaderboard.Leaderboard.t()} | {:error, term()}
Removes an entry from a leaderboard by id. If the id is not in the leaderboard, will return an error.
Examples
iex> {:ok, board} = Leaderboard.create(name: :foo)
iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}])
iex> {:ok, board} = Leaderboard.remove(board, :id1)
iex> Leaderboard.count(board)
1
iex> Leaderboard.remove(board, :id1)
{:error, :entry_not_found}
remove!(CxLeaderboard.Leaderboard.t(), CxLeaderboard.Entry.id()) :: CxLeaderboard.Leaderboard.t()
Same as remove/2
but returns the leaderboard or raises.
start_link(atom(), keyword()) :: GenServer.on_start()
If your chosen storage engine supports server/client operation
(CxLeaderboard.EtsStore
does), then you could set Leaderboard
as a worker
in your application’s children list. For each leaderboard you would just add a
worker, passing it a name. Then in your applicaiton you can use client_for/2
to get the reference to it that you can use to call all the functions in this
module.
Examples
defmodule Foo.Application do
use Application
def start(_type, _args) do
import Supervisor.Spec
# Make sure your data is available as a stream
data_stream = Foo.LeaderboardData.stream()
children = [
worker(CxLeaderboard.Leaderboard, [
name: :global,
data: data_stream
])
]
opts = [strategy: :one_for_one, name: Foo.Supervisor]
Supervisor.start_link(children, opts)
end
end
# Elsewhere in your application
alias CxLeaderboard.Leaderboard
global_lb = Leaderboard.client_for(:global)
global_lb
|> Leaderboard.top()
|> Enum.take(10)
Indexer is configured at the client level (it’s passed to server with each function), therefore if you want the leaderboard to use a custom indexer, all you need to do is:
lb = Leaderboard.client_for(:global, indexer: my_custom_indexer)
See the Stats section of this module’s doc to learn more about indexers.
top(CxLeaderboard.Leaderboard.t()) :: Enumerable.t()
Returns a stream of top leaderboard records.
Examples
iex> {:ok, board} = Leaderboard.create(name: :foo)
iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}])
iex> Leaderboard.top(board) |> Enum.take(1)
[{{-3, :id2}, :id2, {0, {1, 99.0}}}]
update(CxLeaderboard.Leaderboard.t(), CxLeaderboard.Entry.t()) :: {:ok, CxLeaderboard.Leaderboard.t()} | {:error, term()}
Updates a single entry in an existing leaderboard. Invalid entries will return an error. If the id is not in the leaderboard, will return an error.
See Entry section of the module doc for information about entries.
Examples
iex> {:ok, board} = Leaderboard.create(name: :foo)
iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}])
iex> {:ok, board} = Leaderboard.update(board, {-5, :id1})
iex> Leaderboard.get(board, :id1)
{{-5, :id1}, :id1, {0, {1, 99.0}}}
iex> Leaderboard.update(board, {-2, :missing_id})
{:error, :entry_not_found}
update!(CxLeaderboard.Leaderboard.t(), CxLeaderboard.Entry.t()) :: CxLeaderboard.Leaderboard.t()
Same as update/2
but returns the leaderboard or raises.