# ElixirLeaderboard
[](https://travis-ci.org/crossfield/cx_leaderboard)
[](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.