# ElixirLeaderboard
[![Travis](https://img.shields.io/travis/crossfield/cx_leaderboard.svg?style=flat-square)](https://travis-ci.org/crossfield/cx_leaderboard)
[![Hex.pm](https://img.shields.io/hexpm/v/cx_leaderboard.svg?style=flat-square)](https://hex.pm/packages/cx_leaderboard)
A featureful, fast leaderboard based on ets store. Can carry payloads, calculate custom stats, provide nearby entries around any entry, and do many other fun things.
```elixir
alias ElixirLeaderboard.Leaderboard
board =
Leaderboard.create!(name: :global_lb)
|> Leaderboard.populate!([
{{-23, :id1}, :user1},
{{-65, :id2}, :user2},
{{-24, :id3}, :user3},
{{-23, :id4}, :user4},
{{-34, :id5}, :user5}
])
records =
board
|> Leaderboard.top()
|> Enum.to_list()
# Returned records (explained):
# {{score, id}, payload, {index, {rank, percentile}}}
# [ {{-65, :id2}, :user2, {0, {1, 99.0}}},
# {{-65, :id3}, :user3, {1, {1, 99.0}}},
# {{-34, :id5}, :user5, {2, {3, 59.8}}},
# {{-23, :id1}, :user1, {3, {4, 40.2}}},
# {{-23, :id4}, :user4, {4, {4, 40.2}}} ]
```
## Features
* Ranks, percentiles, any custom stats of your choice
* Concurrent reads, sequential writes
* Stream API access to records from the top and the bottom
* O(1) querying of any record by id
* Auto-populating data on leaderboard startup
* Adding, updating, removing, upserting of individual entries in live leaderboard
* Fetching a range of records around a given id (contextual leaderboard)
* Pluggable data stores: `EtsStore` for big boards, `TermStore` for dynamic mini boards
* Atomic full repopulation in O(2n log n) time
* Multi-node support
* Extensibility for storage engines (`ElixirLeaderboard.Storage` behaviour)
## Installation
The package can be installed by adding `elixir_leaderboard` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:elixir_leaderboard, "~> 0.1.0"}
]
end
```
## Documentation
https://hexdocs.pm/elixir_leaderboard/ElixirLeaderboard.Leaderboard.html
## Global Leaderboards
If you want to have a global leaderboard starting at the same time as your application, and running alongside it, all you need to do is declare a
as follows:
```elixir
defmodule Foo.Application do
use Application
def start(_type, _args) do
import Supervisor.Spec
children = [
# This is where you provide a data enumerable (e.g. a stream of paginated
# Postgres results) for leaderboard to auto-populate itself on startup.
# It's best if this is implemented as a Stream to avoid consuming more
# RAM than necessary.
worker(ElixirLeaderboard.Leaderboard, [:global, [data: Foo.MyData.load()]])
]
opts = [strategy: :one_for_one, name: Foo.Supervisor]
Supervisor.start_link(children, opts)
end
end
```
Then you can interact with it anywhere in your app like this:
```elixir
alias ElixirLeaderboard.Leaderboard
global_lb = Leaderboard.client_for(:global)
global_lb
|> Leaderboard.top()
|> Enum.take(10)
```
## Fetching ranges
If you want to get a record and its context (nearby records), you can use a range.
```elixir
Leaderboard.get(board, :id3, -1..1)
# [
# {{-34, :id5}, :user5, {1, {2, 79.4}}},
# {{-24, :id3}, :user3, {2, {3, 59.8}}},
# {{-23, :id1}, :user1, {3, {4, 40.2}}}
# ]
```
## Different ranking flavors
To use different ranking you can just create your own indexer. Here's an example of the above leaderboard only in this case we want sequential ranks.
```elixir
alias ElixirLeaderboard.{Leaderboard, Indexer}
my_indexer = Indexer.new(on_rank:
&Indexer.Stats.sequential_rank_1_99_less_or_equal_percentile/1)
board =
Leaderboard.create!(name: :global_lb, indexer: my_indexer)
|> Leaderboard.populate!([
{{-23, :id1}, :user1},
{{-65, :id2}, :user2},
{{-65, :id3}, :user3},
{{-23, :id4}, :user4},
{{-34, :id5}, :user5}
])
records =
board
|> Leaderboard.top()
|> Enum.to_list()
# Returned records (explained):
# [ {{-65, :id2}, :user2, {0, {1, 99.0}}},
# {{-65, :id3}, :user3, {1, {1, 99.0}}},
# {{-34, :id5}, :user5, {2, {2, 59.8}}},
# {{-23, :id1}, :user1, {3, {3, 40.2}}},
# {{-23, :id4}, :user4, {4, {3, 40.2}}} ]
```
Notice how the resulting ranks are not offset like 1,1,3,4,4 but are sequential like 1,1,2,3,3.
See docs for `ElixirLeaderboard.Indexer.Stats` for various pre-packaged functions you can plug into the indexer, or write your own.
## Mini-leaderboards
Sometimes all you need is to render a quick one-off leaderboard with just a few entries in it. For this you don't have to run a persistent ets, instead you can use `TermStore`.
```elixir
miniboard =
Leaderboard.create!(store: ElixirLeaderboard.TermStore)
|> Leaderboard.populate!(
[
{23, 1},
{65, 2},
{24, 3},
{23, 4},
{34, 5}
]
)
miniboard
|> Leaderboard.top()
|> Enum.take(3)
# [
# {{23, 1}, 1, {0, {1, 99.0}}},
# {{23, 4}, 4, {1, {1, 99.0}}},
# {{24, 3}, 3, {2, {3, 59.8}}}
# ]
```
This would produce a complete full-featured leaderboard that's entirely stored in the `miniboard` variable. All the same API functions work on it.
Note: It is not recommended to use `TermStore` for big leaderboards (as evident from the benchmarks below). A typical use case for it would be to dynamically render a single-page leaderboard among a small group of users.
## Benchmark
These benchmarks use 1 million randomly generated records, however, the same set of records is used for both ets and term leaderboard within each benchmark.
```
Operating System: macOS
CPU Information: Intel(R) Core(TM) i7-6920HQ CPU @ 2.90GHz
Number of Available Cores: 8
Available memory: 16 GB
Elixir 1.6.2
Erlang 20.2.4
Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
parallel: 1
```
### Populating the leaderboard with 1mil entries
Script: [benchmark/populate.exs](benchmark/populate.exs)
```
Name ips average deviation median 99th %
ets 0.21 4.76 s ±0.95% 4.76 s 4.81 s
term 0.169 5.91 s ±0.00% 5.91 s 5.91 s
Comparison:
ets 0.21
term 0.169 - 1.24x slower
```
Summary:
- It takes ~4.76s to populate ets leaderboard with 1 million random scores.
- It takes ~5.91s to populate term leaderboard with 1 million random scores (but you shouldn't).
The leaderboard is fully sorted and indexed at the end.
### Adding an entry to 1mil leaderboard
Script: [benchmark/add_entry.exs](benchmark/add_entry.exs)
```
Name ips average deviation median 99th %
ets 148.95 K 0.00001 s ±88.34% 0.00001 s 0.00002 s
term 0.00034 K 2.92 s ±0.56% 2.92 s 2.94 s
Comparison:
ets 148.95 K
term 0.00034 K - 435227.97x slower
```
As you can see, you should not create a `TermStore` leaderboard with a million entries.
### Getting a -10..10 range from 1mil leaderboard
Script: [benchmark/range.exs](benchmark/range.exs)
```
Name ips average deviation median 99th %
ets 17.84 K 0.0560 ms ±20.66% 0.0530 ms 0.101 ms
term 0.00290 K 345.13 ms ±3.83% 345.04 ms 374.28 ms
Comparison:
ets 17.84 K
term 0.00290 K - 6158.09x slower
```
Another example of how the `TermStore` is not intended for big number of entries.