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

Link to this type t() View Source
t() :: %CxLeaderboard.Leaderboard{
  data: Enumerable.t() | nil,
  indexer: CxLeaderboard.Indexer.t(),
  state: state(),
  store: module()
}

Link to this section Functions

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.

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}}}

Same as add_or_update/2 but returns the leaderboard or raises.

Link to this function async_populate(lb, data) View Source
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

Same as async_populate/2 but returns the leaderboard or raises.

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}}}]

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

Same as clear/1 but returns the leaderboard or raises.

Link to this function client_for(name, store \\ CxLeaderboard.EtsStore) View Source
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.

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
Link to this function create(kwargs \\ []) View Source
create(keyword()) :: {:ok, CxLeaderboard.Leaderboard.t()} | {:error, term()}

Creates a new leaderboard.

Options

Examples

iex> Leaderboard.create(name: :global)
{:ok,
  %Leaderboard{
    state: :global,
    store: CxLeaderboard.EtsStore,
    indexer: %CxLeaderboard.Indexer{},
    data: []
  }
}

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

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}}}
]

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

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.

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}

Same as remove/2 but returns the leaderboard or raises.

Link to this function start_link(name, kwargs) View Source
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.

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}}}]

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}

Same as update/2 but returns the leaderboard or raises.