├── .formatter.exs ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── benchmark ├── add_entry.exs ├── populate.exs └── range.exs ├── config └── config.exs ├── lib ├── cx_leaderboard.ex └── cx_leaderboard │ ├── entry.ex │ ├── ets_store.ex │ ├── ets_store │ ├── ets.ex │ └── writer.ex │ ├── indexer.ex │ ├── indexer │ └── stats.ex │ ├── leaderboard.ex │ ├── record.ex │ ├── storage.ex │ └── term_store.ex ├── mix.exs ├── mix.lock └── test ├── cx_leaderboard_test.exs ├── ets_store_test.exs ├── storage_case.exs ├── term_store_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | line_length: 79 5 | ] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | cx_leaderboard-*.tar 24 | 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.5.3 4 | - 1.6.4 5 | otp_release: 6 | - 19.3 7 | - 20.2 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018 Crossfield Digital 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CxLeaderboard 2 | 3 | [![Travis](https://img.shields.io/travis/maxim/cx_leaderboard.svg?style=flat-square)](https://travis-ci.org/maxim/cx_leaderboard) 4 | [![Hex.pm](https://img.shields.io/hexpm/v/cx_leaderboard.svg?style=flat-square)](https://hex.pm/packages/cx_leaderboard) 5 | 6 | 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. 7 | 8 | ```elixir 9 | alias CxLeaderboard.Leaderboard 10 | 11 | board = 12 | Leaderboard.create!(name: :global_lb) 13 | |> Leaderboard.populate!([ 14 | {{-23, :id1}, :user1}, 15 | {{-65, :id2}, :user2}, 16 | {{-24, :id3}, :user3}, 17 | {{-23, :id4}, :user4}, 18 | {{-34, :id5}, :user5} 19 | ]) 20 | 21 | records = 22 | board 23 | |> Leaderboard.top() 24 | |> Enum.to_list() 25 | 26 | # Returned records (explained): 27 | # {{score, id}, payload, {index, {rank, percentile}}} 28 | # [ {{-65, :id2}, :user2, {0, {1, 99.0}}}, 29 | # {{-65, :id3}, :user3, {1, {1, 99.0}}}, 30 | # {{-34, :id5}, :user5, {2, {3, 59.8}}}, 31 | # {{-23, :id1}, :user1, {3, {4, 40.2}}}, 32 | # {{-23, :id4}, :user4, {4, {4, 40.2}}} ] 33 | ``` 34 | 35 | ## Features 36 | 37 | * Ranks, percentiles, any custom stats of your choice 38 | * Concurrent reads, sequential writes 39 | * Stream API access to records from the top and the bottom 40 | * O(1) querying of any record by id 41 | * Auto-populating data on leaderboard startup 42 | * Adding, updating, removing, upserting of individual entries in live leaderboard 43 | * Fetching a range of records around a given id (contextual leaderboard) 44 | * Pluggable data stores: `EtsStore` for big boards, `TermStore` for dynamic mini boards 45 | * Atomic full repopulation in O(2n log n) time 46 | * Multi-node support 47 | * Extensibility for storage engines (`CxLeaderboard.Storage` behaviour) 48 | 49 | ## Installation 50 | 51 | The package can be installed by adding `cx_leaderboard` to your list of dependencies in `mix.exs`: 52 | 53 | ```elixir 54 | def deps do 55 | [ 56 | {:cx_leaderboard, "~> 0.1.0"} 57 | ] 58 | end 59 | ``` 60 | 61 | ## Documentation 62 | 63 | https://hexdocs.pm/cx_leaderboard/CxLeaderboard.Leaderboard.html 64 | 65 | ## Global Leaderboards 66 | 67 | 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 68 | as follows: 69 | 70 | ```elixir 71 | defmodule Foo.Application do 72 | use Application 73 | 74 | def start(_type, _args) do 75 | import Supervisor.Spec 76 | 77 | children = [ 78 | # This is where you provide a data enumerable (e.g. a stream of paginated 79 | # Postgres results) for leaderboard to auto-populate itself on startup. 80 | # It's best if this is implemented as a Stream to avoid consuming more 81 | # RAM than necessary. 82 | worker(CxLeaderboard.Leaderboard, [:global, [data: Foo.MyData.load()]]) 83 | ] 84 | 85 | opts = [strategy: :one_for_one, name: Foo.Supervisor] 86 | Supervisor.start_link(children, opts) 87 | end 88 | end 89 | ``` 90 | 91 | Then you can interact with it anywhere in your app like this: 92 | 93 | ```elixir 94 | alias CxLeaderboard.Leaderboard 95 | 96 | global_lb = Leaderboard.client_for(:global) 97 | global_lb 98 | |> Leaderboard.top() 99 | |> Enum.take(10) 100 | ``` 101 | 102 | ## Fetching ranges 103 | 104 | If you want to get a record and its context (nearby records), you can use a range. 105 | 106 | ```elixir 107 | Leaderboard.get(board, :id3, -1..1) 108 | # [ 109 | # {{-34, :id5}, :user5, {1, {2, 79.4}}}, 110 | # {{-24, :id3}, :user3, {2, {3, 59.8}}}, 111 | # {{-23, :id1}, :user1, {3, {4, 40.2}}} 112 | # ] 113 | ``` 114 | 115 | ## Different ranking flavors 116 | 117 | 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. 118 | 119 | ```elixir 120 | alias CxLeaderboard.{Leaderboard, Indexer} 121 | 122 | my_indexer = Indexer.new(on_rank: 123 | &Indexer.Stats.sequential_rank_1_99_less_or_equal_percentile/1) 124 | 125 | board = 126 | Leaderboard.create!(name: :global_lb, indexer: my_indexer) 127 | |> Leaderboard.populate!([ 128 | {{-23, :id1}, :user1}, 129 | {{-65, :id2}, :user2}, 130 | {{-65, :id3}, :user3}, 131 | {{-23, :id4}, :user4}, 132 | {{-34, :id5}, :user5} 133 | ]) 134 | 135 | records = 136 | board 137 | |> Leaderboard.top() 138 | |> Enum.to_list() 139 | 140 | # Returned records (explained): 141 | # [ {{-65, :id2}, :user2, {0, {1, 99.0}}}, 142 | # {{-65, :id3}, :user3, {1, {1, 99.0}}}, 143 | # {{-34, :id5}, :user5, {2, {2, 59.8}}}, 144 | # {{-23, :id1}, :user1, {3, {3, 40.2}}}, 145 | # {{-23, :id4}, :user4, {4, {3, 40.2}}} ] 146 | ``` 147 | 148 | Notice how the resulting ranks are not offset like 1,1,3,4,4 but are sequential like 1,1,2,3,3. 149 | 150 | See docs for `CxLeaderboard.Indexer.Stats` for various pre-packaged functions you can plug into the indexer, or write your own. 151 | 152 | ## Mini-leaderboards 153 | 154 | 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`. 155 | 156 | ```elixir 157 | miniboard = 158 | Leaderboard.create!(store: CxLeaderboard.TermStore) 159 | |> Leaderboard.populate!( 160 | [ 161 | {23, 1}, 162 | {65, 2}, 163 | {24, 3}, 164 | {23, 4}, 165 | {34, 5} 166 | ] 167 | ) 168 | 169 | miniboard 170 | |> Leaderboard.top() 171 | |> Enum.take(3) 172 | # [ 173 | # {{23, 1}, 1, {0, {1, 99.0}}}, 174 | # {{23, 4}, 4, {1, {1, 99.0}}}, 175 | # {{24, 3}, 3, {2, {3, 59.8}}} 176 | # ] 177 | ``` 178 | 179 | This would produce a complete full-featured leaderboard that's entirely stored in the `miniboard` variable. All the same API functions work on it. 180 | 181 | 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. 182 | 183 | ## Benchmark 184 | 185 | 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. 186 | 187 | ``` 188 | Operating System: macOS 189 | CPU Information: Intel(R) Core(TM) i7-6920HQ CPU @ 2.90GHz 190 | Number of Available Cores: 8 191 | Available memory: 16 GB 192 | Elixir 1.6.2 193 | Erlang 20.2.4 194 | Benchmark suite executing with the following configuration: 195 | warmup: 2 s 196 | time: 5 s 197 | parallel: 1 198 | ``` 199 | 200 | ### Populating the leaderboard with 1mil entries 201 | 202 | Script: [benchmark/populate.exs](benchmark/populate.exs) 203 | 204 | ``` 205 | Name ips average deviation median 99th % 206 | ets 0.21 4.76 s ±0.95% 4.76 s 4.81 s 207 | term 0.169 5.91 s ±0.00% 5.91 s 5.91 s 208 | 209 | Comparison: 210 | ets 0.21 211 | term 0.169 - 1.24x slower 212 | ``` 213 | 214 | Summary: 215 | 216 | - It takes ~4.76s to populate ets leaderboard with 1 million random scores. 217 | - It takes ~5.91s to populate term leaderboard with 1 million random scores (but you shouldn't). 218 | 219 | The leaderboard is fully sorted and indexed at the end. 220 | 221 | ### Adding an entry to 1mil leaderboard 222 | 223 | Script: [benchmark/add_entry.exs](benchmark/add_entry.exs) 224 | 225 | ``` 226 | Name ips average deviation median 99th % 227 | ets 148.95 K 0.00001 s ±88.34% 0.00001 s 0.00002 s 228 | term 0.00034 K 2.92 s ±0.56% 2.92 s 2.94 s 229 | 230 | Comparison: 231 | ets 148.95 K 232 | term 0.00034 K - 435227.97x slower 233 | ``` 234 | 235 | As you can see, you should not create a `TermStore` leaderboard with a million entries. 236 | 237 | ### Getting a -10..10 range from 1mil leaderboard 238 | 239 | Script: [benchmark/range.exs](benchmark/range.exs) 240 | 241 | ``` 242 | Name ips average deviation median 99th % 243 | ets 17.84 K 0.0560 ms ±20.66% 0.0530 ms 0.101 ms 244 | term 0.00290 K 345.13 ms ±3.83% 345.04 ms 374.28 ms 245 | 246 | Comparison: 247 | ets 17.84 K 248 | term 0.00290 K - 6158.09x slower 249 | ``` 250 | 251 | Another example of how the `TermStore` is not intended for big number of entries. 252 | 253 | -------------------------------------------------------------------------------- /benchmark/add_entry.exs: -------------------------------------------------------------------------------- 1 | samples = 2 | Stream.iterate({:rand.uniform(1_000_000), 1}, fn 3 | {_, id} -> {:rand.uniform(1_000_000), id + 1} 4 | end) 5 | 6 | alias CxLeaderboard.Leaderboard 7 | 8 | one_mil = samples |> Enum.take(1_000_000) 9 | 10 | ets_board = 11 | Leaderboard.create!(name: :benchmark, store: CxLeaderboard.EtsStore) 12 | |> Leaderboard.populate!(one_mil) 13 | 14 | term_board = 15 | Leaderboard.create!(store: CxLeaderboard.TermStore) 16 | |> Leaderboard.populate!(one_mil) 17 | 18 | Benchee.run(%{ 19 | "ets" => fn -> Leaderboard.add(ets_board, {500_000, 2_000_000}) end, 20 | "term" => fn -> Leaderboard.add(term_board, {500_000, 2_000_000}) end 21 | }) 22 | -------------------------------------------------------------------------------- /benchmark/populate.exs: -------------------------------------------------------------------------------- 1 | samples = 2 | Stream.iterate({:rand.uniform(1_000_000), 1}, fn 3 | {_, id} -> {:rand.uniform(1_000_000), id + 1} 4 | end) 5 | 6 | alias CxLeaderboard.Leaderboard 7 | 8 | ets_board = Leaderboard.create!(name: :benchmark, store: CxLeaderboard.EtsStore) 9 | term_board = Leaderboard.create!(store: CxLeaderboard.TermStore) 10 | one_mil = samples |> Enum.take(1_000_000) 11 | 12 | Benchee.run(%{ 13 | "ets" => fn -> Leaderboard.populate(ets_board, one_mil) end, 14 | "term" => fn -> Leaderboard.populate(term_board, one_mil) end 15 | }) 16 | -------------------------------------------------------------------------------- /benchmark/range.exs: -------------------------------------------------------------------------------- 1 | samples = 2 | Stream.iterate({:rand.uniform(1_000_000), 1}, fn 3 | {_, id} -> {:rand.uniform(1_000_000), id + 1} 4 | end) 5 | 6 | alias CxLeaderboard.Leaderboard 7 | 8 | one_mil = samples |> Enum.take(1_000_000) 9 | 10 | ets_board = 11 | Leaderboard.create!(name: :benchmark, store: CxLeaderboard.EtsStore) 12 | |> Leaderboard.populate!(one_mil) 13 | 14 | term_board = 15 | Leaderboard.create!(store: CxLeaderboard.TermStore) 16 | |> Leaderboard.populate!(one_mil) 17 | 18 | Benchee.run(%{ 19 | "ets" => fn -> Leaderboard.get(ets_board, 500_000, -10..10) end, 20 | "term" => fn -> Leaderboard.get(term_board, 500_000, -10..10) end 21 | }) 22 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :cx_leaderboard, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:cx_leaderboard, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/cx_leaderboard.ex: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard do 2 | @moduledoc false 3 | end 4 | -------------------------------------------------------------------------------- /lib/cx_leaderboard/entry.ex: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard.Entry do 2 | @moduledoc """ 3 | Entry is the tuple-based structure that you send into the leaderboard. 4 | """ 5 | 6 | @typedoc """ 7 | Use this format when sending your entries to a leaderboard. See below for 8 | breakdowns of each type. 9 | """ 10 | @type t :: {key, payload} 11 | 12 | @type key :: {score, id} | {score, tiebreaker, id} 13 | 14 | @typedoc """ 15 | A term on which the leaderboard should be ranked. Any term can work, but 16 | numeric ones make the most sense. 17 | """ 18 | @type score :: term 19 | 20 | @typedoc """ 21 | Determines which of the scores appears first if scores are equal. The entry_id 22 | is always implied as the final tiebreaker, regardless of any other tiebreakers 23 | provided. 24 | """ 25 | @type tiebreaker :: term 26 | 27 | @typedoc """ 28 | Must uniquely identify a record in the leaderboard. 29 | """ 30 | @type id :: term 31 | 32 | @typedoc """ 33 | Allows storing any free-form data with each leaderboard record. 34 | """ 35 | @type payload :: term 36 | 37 | def format(input = {{_, _, _}, _}), do: input 38 | def format(input = {{_, _}, _}), do: input 39 | def format(input = {_, _, id}), do: {input, id} 40 | def format(input = {_, id}), do: {input, id} 41 | def format(_), do: {:error, :bad_entry} 42 | 43 | def get_score({{score, _, _}, _}), do: score 44 | def get_score({{score, _}, _}), do: score 45 | 46 | def get_tiebreak({{_, tiebreak, _}, _}), do: tiebreak 47 | def get_tiebreak({{_, _}, _}), do: nil 48 | 49 | def get_id({{_, _, id}, _}), do: id 50 | def get_id({{_, id}, _}), do: id 51 | 52 | def get_payload({_, payload}), do: payload 53 | end 54 | -------------------------------------------------------------------------------- /lib/cx_leaderboard/ets_store.ex: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard.EtsStore do 2 | @moduledoc """ 3 | Use this storage engine to get efficient leaderboards powered by ets. Supports 4 | client/server mode via `CxLeaderboard.Leaderboard.start_link/1` and 5 | `CxLeaderboard.Leaderboard.async_populate/2`. This is the default storage 6 | engine. 7 | """ 8 | 9 | @behaviour CxLeaderboard.Storage 10 | alias CxLeaderboard.EtsStore.{Ets, Writer} 11 | 12 | ## Writers 13 | 14 | @doc false 15 | def create(kwargs) do 16 | name = Keyword.get(kwargs, :name) 17 | 18 | case GenServer.start_link(Writer, name, name: name) do 19 | {:ok, _} -> {:ok, name} 20 | error -> error 21 | end 22 | end 23 | 24 | @doc false 25 | def clear(name) do 26 | with :ok <- GenServer.stop(name), 27 | {:ok, _} <- GenServer.start_link(Writer, name, name: name) do 28 | {:ok, name} 29 | end 30 | end 31 | 32 | @doc false 33 | def populate(name, data, indexer) do 34 | process_multi_call(name, {:populate, data, indexer}) 35 | end 36 | 37 | @doc false 38 | def async_populate(name, data, indexer) do 39 | :abcast = GenServer.abcast(name, {:populate, data, indexer}) 40 | {:ok, name} 41 | end 42 | 43 | @doc false 44 | def add(name, entry, indexer) do 45 | process_multi_call(name, {:add, entry, indexer}) 46 | end 47 | 48 | @doc false 49 | def remove(name, id, indexer) do 50 | process_multi_call(name, {:remove, id, indexer}) 51 | end 52 | 53 | @doc false 54 | def update(name, entry, indexer) do 55 | process_multi_call(name, {:update, entry, indexer}) 56 | end 57 | 58 | @doc false 59 | def add_or_update(name, entry, indexer) do 60 | process_multi_call(name, {:add_or_update, entry, indexer}) 61 | end 62 | 63 | @doc false 64 | def start_link(lb = %{state: name}) do 65 | GenServer.start_link(Writer, {name, lb}, name: name) 66 | end 67 | 68 | @doc false 69 | def get_lb(name) do 70 | GenServer.call(name, :get_lb) 71 | end 72 | 73 | ## Readers 74 | 75 | @doc false 76 | defdelegate get(name, id), to: Ets 77 | 78 | @doc false 79 | defdelegate get(name, id, range), to: Ets 80 | 81 | @doc false 82 | defdelegate top(name), to: Ets 83 | 84 | @doc false 85 | defdelegate bottom(name), to: Ets 86 | 87 | @doc false 88 | defdelegate count(name), to: Ets 89 | 90 | ## Private 91 | 92 | defp process_multi_call(name, message) do 93 | name 94 | |> GenServer.multi_call(message) 95 | |> format_multi_call_reply(name) 96 | end 97 | 98 | defp format_multi_call_reply(replies = {nodes, bad_nodes}, name) do 99 | errors = collect_errors(replies) 100 | node_count = Enum.count(nodes) + Enum.count(bad_nodes) 101 | 102 | case {node_count, errors} do 103 | # no errors anywhere 104 | {_, []} -> 105 | {:ok, name} 106 | 107 | # only one node and one error, collapse to a simple error 108 | {1, [{_, reason}]} -> 109 | {:error, reason} 110 | 111 | # only one node but multiple errors, return all reasons in a list 112 | {1, errors} -> 113 | {:error, Enum.map(errors, fn {_, reason} -> reason end)} 114 | 115 | # multiple nodes and errors, return node-error pairs 116 | {_, errors} -> 117 | {:error, errors} 118 | end 119 | end 120 | 121 | defp collect_errors({nodes, bad_nodes}) do 122 | errors = 123 | nodes 124 | |> Enum.filter(&reply_has_errors?/1) 125 | |> Enum.map(fn {node, {:error, reason}} -> {node, reason} end) 126 | 127 | Enum.reduce(bad_nodes, errors, fn bad_node, errors -> 128 | [{bad_node, :bad_node} | errors] 129 | end) 130 | end 131 | 132 | defp reply_has_errors?({_, {:error, _}}), do: true 133 | defp reply_has_errors?(_), do: false 134 | end 135 | -------------------------------------------------------------------------------- /lib/cx_leaderboard/ets_store/ets.ex: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard.EtsStore.Ets do 2 | @moduledoc false 3 | 4 | alias CxLeaderboard.{Indexer, Entry} 5 | 6 | @meta_table_settings [ 7 | :set, 8 | :named_table, 9 | :protected, 10 | read_concurrency: true 11 | ] 12 | 13 | @entries_table_settings [ 14 | :ordered_set, 15 | :named_table, 16 | :protected, 17 | read_concurrency: true 18 | ] 19 | 20 | @index_table_settings [ 21 | :set, 22 | :named_table, 23 | :protected, 24 | read_concurrency: true 25 | ] 26 | 27 | def init(name) do 28 | create_meta_table(name) 29 | 30 | set_meta(name, [ 31 | {:entries_table_name, create_entries_table(name)}, 32 | {:index_table_name, create_index_table(name)}, 33 | {:status, :started}, 34 | {:count, 0} 35 | ]) 36 | 37 | {:ok, name} 38 | end 39 | 40 | def add(name, entry, indexer) do 41 | id = Entry.get_id(entry) 42 | 43 | case get(name, id) do 44 | nil -> 45 | modify_with_reindex(indexer, name, +1, fn table -> 46 | :ets.insert(table, entry) 47 | end) 48 | 49 | _ -> 50 | {:error, :entry_already_exists} 51 | end 52 | end 53 | 54 | def remove(name, id, indexer) do 55 | case get(name, id) do 56 | {key, _, _} -> 57 | modify_with_reindex(indexer, name, -1, fn table -> 58 | :ets.delete(table, key) 59 | end) 60 | 61 | _ -> 62 | {:error, :entry_not_found} 63 | end 64 | end 65 | 66 | def update(name, entry, indexer) do 67 | id = Entry.get_id(entry) 68 | 69 | case get(name, id) do 70 | nil -> 71 | {:error, :entry_not_found} 72 | 73 | {key, _, _} -> 74 | modify_with_reindex(indexer, name, 0, fn table -> 75 | :ets.delete(table, key) 76 | :ets.insert(table, entry) 77 | end) 78 | end 79 | end 80 | 81 | def add_or_update(name, entry, indexer) do 82 | id = Entry.get_id(entry) 83 | 84 | case get(name, id) do 85 | nil -> 86 | modify_with_reindex(indexer, name, +1, fn table -> 87 | :ets.insert(table, entry) 88 | end) 89 | 90 | {key, _, _} -> 91 | modify_with_reindex(indexer, name, 0, fn table -> 92 | :ets.delete(table, key) 93 | :ets.insert(table, entry) 94 | end) 95 | end 96 | end 97 | 98 | def populate(name, data, indexer) do 99 | t1 = get_timestamp() 100 | set_meta(name, {:status, :populating}) 101 | 102 | old_table = get_meta(name, :entries_table_name) 103 | old_index = get_meta(name, :index_table_name) 104 | 105 | suffix = get_rand_suffix() 106 | 107 | {new_table, count} = insert_entries(name, data, suffix) 108 | new_index = build_index(indexer, name, new_table, count, suffix) 109 | 110 | set_meta(name, [ 111 | {:entries_table_name, new_table}, 112 | {:index_table_name, new_index}, 113 | {:status, :normal}, 114 | {:count, count} 115 | ]) 116 | 117 | if old_table, do: :ets.delete(old_table) 118 | if old_index, do: :ets.delete(old_index) 119 | 120 | t2 = get_timestamp() 121 | {:ok, {count, t2 - t1}} 122 | end 123 | 124 | def get(name, id) do 125 | with table when not is_nil(table) <- get_meta(name, :entries_table_name), 126 | index when not is_nil(index) <- get_meta(name, :index_table_name), 127 | {:ok, index_term = {_, key, _}} <- lookup(index, id), 128 | {:ok, table_term = {_, _}} <- lookup(table, key) do 129 | build_record(table_term, index_term) 130 | else 131 | _ -> nil 132 | end 133 | end 134 | 135 | def get(name, id, start..finish) do 136 | case get(name, id) do 137 | nil -> 138 | [] 139 | 140 | {key, _, _} -> 141 | table = get_meta(name, :entries_table_name) 142 | 143 | {min, max} = Enum.min_max([start, finish]) 144 | 145 | start_key = key_at_offset(table, key, min) 146 | finish_key = key_at_offset(table, key, max) 147 | 148 | results = 149 | table 150 | |> keys_between(start_key, finish_key) 151 | |> Enum.map(fn 152 | {_, id} -> get(name, id) 153 | {_, _, id} -> get(name, id) 154 | end) 155 | 156 | if finish < start, do: Enum.reverse(results), else: results 157 | end 158 | end 159 | 160 | def top(name) do 161 | table_name = get_meta(name, :entries_table_name) 162 | 163 | if table_name do 164 | stream_keys(table_name) 165 | |> Stream.map(fn 166 | {_, _, id} -> get(name, id) 167 | {_, id} -> get(name, id) 168 | end) 169 | else 170 | [] 171 | end 172 | end 173 | 174 | def bottom(name) do 175 | table_name = get_meta(name, :entries_table_name) 176 | 177 | if table_name do 178 | reverse_stream_keys(table_name) 179 | |> Stream.map(fn 180 | {_, _, id} -> get(name, id) 181 | {_, id} -> get(name, id) 182 | end) 183 | else 184 | [] 185 | end 186 | end 187 | 188 | def count(name) do 189 | get_meta(name, :count) 190 | end 191 | 192 | ## Private 193 | 194 | defp keys_between(_, key, key), do: [key] 195 | 196 | defp keys_between(table, key1, key2) do 197 | [key1 | keys_between(table, :ets.next(table, key1), key2)] 198 | end 199 | 200 | defp key_at_offset(_, key, 0), do: key 201 | 202 | defp key_at_offset(table, key, amount) do 203 | direction = if amount > 0, do: :next, else: :prev 204 | 205 | Enum.reduce_while(0..(abs(amount) - 1), key, fn _, key -> 206 | next_key = apply(:ets, direction, [table, key]) 207 | 208 | case next_key do 209 | :"$end_of_table" -> {:halt, key} 210 | next_key -> {:cont, next_key} 211 | end 212 | end) 213 | end 214 | 215 | defp modify_with_reindex(indexer, name, count_delta, modification) do 216 | t1 = get_timestamp() 217 | set_meta(name, {:status, :reindexing}) 218 | 219 | table = get_meta(name, :entries_table_name) 220 | old_index = get_meta(name, :index_table_name) 221 | new_count = get_meta(name, :count) + count_delta 222 | 223 | modification.(table) 224 | 225 | new_index = build_index(indexer, name, table, new_count) 226 | 227 | set_meta(name, [ 228 | {:index_table_name, new_index}, 229 | {:status, :normal}, 230 | {:count, new_count} 231 | ]) 232 | 233 | if old_index, do: :ets.delete(old_index) 234 | 235 | t2 = get_timestamp() 236 | {:ok, {new_count, t2 - t1}} 237 | end 238 | 239 | defp insert_entries(name, data, suffix) do 240 | table = create_entries_table(name, suffix) 241 | count = Enum.count(data, &:ets.insert(table, &1)) 242 | {table, count} 243 | end 244 | 245 | defp build_index(indexer, name, table, count, suffix \\ get_rand_suffix()) do 246 | index = create_index_table(name, suffix) 247 | 248 | table 249 | |> stream_keys() 250 | |> Indexer.index(count, indexer) 251 | |> Enum.each(&:ets.insert(index, &1)) 252 | 253 | index 254 | end 255 | 256 | defp build_record({key, payload}, {_, _, stats}) do 257 | {key, payload, stats} 258 | end 259 | 260 | defp stream_keys(table_name) do 261 | Stream.unfold(:ets.first(table_name), fn 262 | :"$end_of_table" -> nil 263 | key -> {key, :ets.next(table_name, key)} 264 | end) 265 | end 266 | 267 | defp reverse_stream_keys(table_name) do 268 | Stream.unfold(:ets.last(table_name), fn 269 | :"$end_of_table" -> nil 270 | key -> {key, :ets.prev(table_name, key)} 271 | end) 272 | end 273 | 274 | defp set_meta(name, record) do 275 | name 276 | |> meta_table_name() 277 | |> :ets.insert(record) 278 | end 279 | 280 | defp get_meta(name, key) do 281 | table_name = meta_table_name(name) 282 | 283 | case :ets.info(table_name) do 284 | :undefined -> 285 | nil 286 | 287 | _ -> 288 | case lookup(table_name, key) do 289 | {:ok, nil} -> nil 290 | {:ok, {_, value}} -> value 291 | end 292 | end 293 | end 294 | 295 | defp meta_table_name(name) do 296 | :"cxlb_#{name}_meta" 297 | end 298 | 299 | defp create_meta_table(name) do 300 | name |> meta_table_name() |> :ets.new(@meta_table_settings) 301 | end 302 | 303 | defp create_entries_table(name, suffix \\ get_rand_suffix()) do 304 | :ets.new(:"cxlb_#{name}_entries_#{suffix}", @entries_table_settings) 305 | end 306 | 307 | defp create_index_table(name, suffix \\ get_rand_suffix()) do 308 | :ets.new(:"cxlb_#{name}_index_#{suffix}", @index_table_settings) 309 | end 310 | 311 | defp lookup(table_name, key) do 312 | case :ets.lookup(table_name, key) do 313 | [] -> 314 | {:ok, nil} 315 | 316 | [value] -> 317 | {:ok, value} 318 | 319 | error -> 320 | error 321 | end 322 | end 323 | 324 | defp get_timestamp do 325 | :os.system_time(:millisecond) 326 | end 327 | 328 | defp get_rand_suffix(bytes \\ 10) do 329 | 1..bytes 330 | |> Enum.map(fn _ -> :rand.uniform(255) end) 331 | |> :binary.list_to_bin() 332 | |> Base.url_encode64(padding: false) 333 | end 334 | end 335 | -------------------------------------------------------------------------------- /lib/cx_leaderboard/ets_store/writer.ex: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard.EtsStore.Writer do 2 | @moduledoc false 3 | 4 | use GenServer 5 | alias CxLeaderboard.EtsStore.Ets 6 | 7 | def init({name, lb = %{data: data, indexer: indexer}}) do 8 | Ets.init(name) 9 | Ets.populate(name, data, indexer) 10 | {:ok, {name, lb}} 11 | end 12 | 13 | def init(name) do 14 | Ets.init(name) 15 | {:ok, {name, nil}} 16 | end 17 | 18 | def handle_cast({:populate, data, indexer}, state = {name, _}) do 19 | Ets.populate(name, data, indexer) 20 | {:noreply, state} 21 | end 22 | 23 | def handle_call({:populate, data, indexer}, _from, state = {name, _}) do 24 | result = Ets.populate(name, data, indexer) 25 | {:reply, result, state} 26 | end 27 | 28 | def handle_call({:add, entry, indexer}, _from, state = {name, _}) do 29 | result = Ets.add(name, entry, indexer) 30 | {:reply, result, state} 31 | end 32 | 33 | def handle_call({:remove, id, indexer}, _from, state = {name, _}) do 34 | result = Ets.remove(name, id, indexer) 35 | {:reply, result, state} 36 | end 37 | 38 | def handle_call({:update, entry, indexer}, _from, state = {name, _}) do 39 | result = Ets.update(name, entry, indexer) 40 | {:reply, result, state} 41 | end 42 | 43 | def handle_call({:add_or_update, entry, indexer}, _from, state = {name, _}) do 44 | result = Ets.add_or_update(name, entry, indexer) 45 | {:reply, result, state} 46 | end 47 | 48 | def handle_call(:get_lb, _from, state = {_, lb}) do 49 | {:reply, lb, state} 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/cx_leaderboard/indexer.ex: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard.Indexer do 2 | @moduledoc """ 3 | Indexer walks the entire leaderboard and calculates the needed stats, such as 4 | rank and percentile. 5 | 6 | You can customize the stats by creating a custom indexer — a struct consisting 7 | of 2 callback functions: 8 | 9 | - `on_rank` is called when the indexer finishes scanning a set of equal 10 | scores, and moves onto a lower score 11 | 12 | - `on_entry` is called for every entry 13 | 14 | It's important to avoid doing anything `on_entry` that can instead be done 15 | `on_rank`, since we don't want to unnecessarily slow down the indexer. 16 | 17 | This library comes with a bunch of pre-made `on_rank` functions for different 18 | flavor of rank and percentile calculation. See `CxLeaderboard.Indexer.Stats` 19 | documentation for what's available. 20 | 21 | ## The on_rank callback 22 | 23 | Indexer walks through the sorted dataset of all entries from the highest to 24 | the lowest score. Every time a score is different between entries, it runs the 25 | `on_rank` callback for the rank it just finished scanning. The return value of 26 | the function is added to every record in the rank it just walked. 27 | 28 | The function receives the following tuple as the argument: 29 | 30 | {total_leadeboard_size, chunk_index, chunk_position, chunk_size} 31 | 32 | - `total_leaderboard_size` - total number of entries in the leaderboard 33 | - `chunk_index` - zero-based counter for how many different ranks we have 34 | seen so far 35 | - `chunk_position` - zero-based position where this rank started in the 36 | leaderboard 37 | - `chunk_size` - how many equal scores are in this rank 38 | 39 | Based on these values the function can perform any kind of calculation and 40 | return any term as a result. 41 | 42 | Let's see an example of walking through a mini-leaderboard, and see what 43 | numbers get passed into the `on_rank` function. 44 | 45 | walking score total_size chunk_index chunk_position chunk_size 46 | | 3 n/a n/a n/a n/a 47 | | 3 6 0 0 2 48 | | 2 n/a n/a n/a n/a 49 | | 2 n/a n/a n/a n/a 50 | | 2 6 1 2 3 51 | V 1 6 2 5 1 52 | 53 | As the indexer walks the leaderboard, it will only call the `on_rank` function 54 | on the rows where score is about to change, therefore some of the rows are 55 | marked n/a. 56 | 57 | ## The on_entry callback 58 | 59 | An `on_entry` callback is similar to `on_rank` but it receives different 60 | parameters, and it's called on every entry. Its result is added to the entry 61 | for which it's called. 62 | 63 | The function receives the following tuple as the argument: 64 | 65 | {entry_index, entry_id, entry_key, rank_stats} 66 | 67 | - `entry_index` - global position in the leaderboard (top is 0) 68 | - `entry_id` - the id used for fetching records 69 | - `entry_key` - either `{score, id}` or `{score, tiebreaker, id}` depending 70 | on what was inserted 71 | - `rank_stats` - the return value of the `on_rank` function 72 | 73 | Due to `rank_stats` parameter it's possible to make more granular calculations 74 | based on whatever was provided by the `on_rank` function. 75 | """ 76 | 77 | alias CxLeaderboard.Indexer.Stats 78 | alias CxLeaderboard.Entry 79 | 80 | defstruct on_rank: &Stats.offset_rank_1_99_less_or_equal_percentile/1, 81 | on_entry: &Stats.global_index/1 82 | 83 | @type t :: %__MODULE__{ 84 | on_rank: Indexer.on_rank(), 85 | on_entry: Indexer.on_entry() 86 | } 87 | 88 | @type on_rank :: 89 | ({ 90 | non_neg_integer, 91 | non_neg_integer, 92 | non_neg_integer, 93 | non_neg_integer 94 | } -> 95 | term) 96 | 97 | @type on_entry :: ({non_neg_integer, term, Entry.key(), term} -> term) 98 | 99 | @doc """ 100 | Create a custom indexer by supplying 2 functions: `on_rank` and `on_entry`. 101 | See `CxLeaderboard.Indexer.Stats` for available functions, or implement custom 102 | ones. 103 | """ 104 | @spec new(keyword()) :: t() 105 | def new(kwargs) do 106 | on_rank = Keyword.get(kwargs, :on_rank, nil) 107 | on_entry = Keyword.get(kwargs, :on_entry, nil) 108 | %__MODULE__{on_rank: on_rank, on_entry: on_entry} 109 | end 110 | 111 | @doc """ 112 | Same as `index/3` but counts the elements for you so that there is no need to 113 | supply that number. This is inefficient if you already know the total count. 114 | """ 115 | def index(keys) do 116 | index(keys, Enum.count(keys)) 117 | end 118 | 119 | @doc """ 120 | Build leaderboard index from an enumerable containing `Entry.key()`-type 121 | elements. Supply the total count for efficiency. 122 | """ 123 | def index(keys, cnt, indexer \\ %__MODULE__{}) 124 | def index(_, 0, _), do: [] 125 | 126 | def index(keys, cnt, indexer) do 127 | keys 128 | |> Stream.chunk_while( 129 | {indexer, cnt}, 130 | &rank_split/2, 131 | &rank_done/1 132 | ) 133 | |> Stream.concat() 134 | end 135 | 136 | defp rank_split(key, {indexer, cnt}) do 137 | {:cont, {indexer, cnt, 0, 0, 1, [{key, 0}]}} 138 | end 139 | 140 | defp rank_split( 141 | key, 142 | acc = {indexer, cnt, c_i, c_pos, c_size, buf = [{_, i} | _]} 143 | ) do 144 | if score_changed?(key, buf) do 145 | {:cont, flush(acc), {indexer, cnt, c_i + 1, i + 1, 1, [{key, i + 1}]}} 146 | else 147 | {:cont, {indexer, cnt, c_i, c_pos, c_size + 1, [{key, i + 1} | buf]}} 148 | end 149 | end 150 | 151 | defp rank_done({_, _, _, []}), do: {:cont, []} 152 | defp rank_done(acc), do: {:cont, flush(acc), {}} 153 | 154 | defp score_changed?({score, _, _}, [{{score, _, _}, _} | _]), do: false 155 | defp score_changed?({score, _, _}, [{{score, _}, _} | _]), do: false 156 | defp score_changed?({score, _}, [{{score, _, _}, _} | _]), do: false 157 | defp score_changed?({score, _}, [{{score, _}, _} | _]), do: false 158 | defp score_changed?(_, _), do: true 159 | 160 | defp flush({indexer, cnt, c_i, c_pos, c_size, buf}) do 161 | rank_stats = indexer.on_rank.({cnt, c_i, c_pos, c_size}) 162 | 163 | Stream.map(buf, fn 164 | {key = {_, _, id}, i} -> 165 | {id, key, {indexer.on_entry.({i, id, key, rank_stats}), rank_stats}} 166 | 167 | {key = {_, id}, i} -> 168 | {id, key, {indexer.on_entry.({i, id, key, rank_stats}), rank_stats}} 169 | end) 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /lib/cx_leaderboard/indexer/stats.ex: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard.Indexer.Stats do 2 | @moduledoc """ 3 | This module is full of functions that can be used in a custom indexer. Each 4 | uses a different way of calculating stats. Do you want your ranks to go 5 | sequentially, like `1, 1, 2`? Then choose one of the `sequential_rank_*` 6 | functions. Want them offset instead, like `1, 1, 3`? Choose one of the 7 | `offset_rank_*` functions. If there is something else you want to do that 8 | isn't available here, you are welcome to implement your own function. 9 | 10 | Most of the functions here are meant to be given as `on_rank` callback. See 11 | description of each function to find out whether it's intended for `on_rank` 12 | or `on_entry`. 13 | 14 | The functions used by default are: 15 | 16 | on_rank: &Stats.offset_rank_1_99_less_or_equal_percentile/1 17 | on_entry: &Stats.global_index/1 18 | """ 19 | 20 | @doc """ 21 | An `on_rank` function. Calculates ranks with an offset (e.g. 1,1,3) and 22 | percentiles based on all lower scores, and half the equal scores. 23 | """ 24 | def offset_rank_midpoint_percentile({cnt, _, c_pos, c_size}) do 25 | rank = c_pos + 1 26 | lower_scores_count = cnt - c_pos - c_size 27 | percentile = (lower_scores_count + 0.5 * c_size) / cnt * 100 28 | {rank, percentile} 29 | end 30 | 31 | @doc """ 32 | An `on_rank` function. Calculates ranks with an offset (e.g. 1,1,3) and 33 | percentiles based on all lower scores. 34 | """ 35 | def offset_rank_less_than_percentile({cnt, _, c_pos, c_size}) do 36 | rank = c_pos + 1 37 | lower_scores_count = cnt - c_pos - c_size 38 | percentile = lower_scores_count / cnt * 100 39 | {rank, percentile} 40 | end 41 | 42 | @doc """ 43 | An `on_rank` function. Calculates ranks with an offset (e.g. 1,1,3) and 44 | percentiles based on all lower and equal scores. 45 | """ 46 | def offset_rank_less_than_or_equal_percentile({cnt, _, c_pos, _}) do 47 | rank = c_pos + 1 48 | same_or_lower_scores_count = cnt - c_pos 49 | percentile = same_or_lower_scores_count / cnt * 100 50 | {rank, percentile} 51 | end 52 | 53 | @doc """ 54 | An `on_rank` function. Calculates ranks with an offset (e.g. 1,1,3) and 55 | percentiles based on all lower scores and equal scores, then squeezes the 56 | percentile into 1-99 range. 57 | 58 | This is the default choice. 59 | """ 60 | def offset_rank_1_99_less_or_equal_percentile({cnt, _, c_pos, _}) do 61 | rank = c_pos + 1 62 | same_or_lower_scores_count = cnt - c_pos 63 | percentile = same_or_lower_scores_count / cnt * 98 + 1 64 | {rank, percentile} 65 | end 66 | 67 | @doc """ 68 | An `on_rank` function. Calculates ranks sequentially (e.g. 1,1,2) and 69 | percentiles based on all lower scores, and half the equal scores. 70 | """ 71 | def sequential_rank_midpoint_percentile({cnt, c_i, c_pos, c_size}) do 72 | rank = c_i + 1 73 | lower_scores_count = cnt - c_pos - c_size 74 | percentile = (lower_scores_count + 0.5 * c_size) / cnt * 100 75 | {rank, percentile} 76 | end 77 | 78 | @doc """ 79 | An `on_rank` function. Calculates ranks sequentially (e.g. 1,1,2) and 80 | percentiles based on all lower scores. 81 | """ 82 | def sequential_rank_less_than_percentile({cnt, c_i, c_pos, c_size}) do 83 | rank = c_i + 1 84 | lower_scores_count = cnt - c_pos - c_size 85 | percentile = lower_scores_count / cnt * 100 86 | {rank, percentile} 87 | end 88 | 89 | @doc """ 90 | An `on_rank` function. Calculates ranks sequentially (e.g. 1,1,2) and 91 | percentiles based on all lower and equal scores. 92 | """ 93 | def sequential_rank_less_than_or_equal_percentile({cnt, c_i, c_pos, _}) do 94 | rank = c_i + 1 95 | same_or_lower_scores_count = cnt - c_pos 96 | percentile = same_or_lower_scores_count / cnt * 100 97 | {rank, percentile} 98 | end 99 | 100 | @doc """ 101 | An `on_rank` function. Calculates ranks sequentially (e.g. 1,1,2) and 102 | percentiles based on all lower scores and equal scores, then squeezes the 103 | percentile into 1-99 range. 104 | """ 105 | def sequential_rank_1_99_less_or_equal_percentile({cnt, c_i, c_pos, _}) do 106 | rank = c_i + 1 107 | same_or_lower_scores_count = cnt - c_pos 108 | percentile = same_or_lower_scores_count / cnt * 98 + 1 109 | {rank, percentile} 110 | end 111 | 112 | @doc """ 113 | An `on_entry` function. Provides the global index in the leaderboard for each 114 | record. 115 | 116 | This is the default choice. 117 | """ 118 | def global_index({i, _, _, _}) do 119 | i 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/cx_leaderboard/leaderboard.ex: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard.Leaderboard do 2 | @moduledoc """ 3 | 4 | Leaderboard is a lightweight database designed to optimize storing and sorting 5 | data based on ranked scores. It has the following abilities: 6 | 7 | - Create any number of leaderboards 8 | - Store scores and payloads 9 | - Calculate ranks, percentiles, and other stats 10 | - Use custom ranking, percentile, and other stat functions 11 | - Provide a sorted stream of all records 12 | - Support custom tie-breakers for records of the same rank 13 | - Provide a range of records around a specific id (contextual leaderboard) 14 | - Add/remove/update/upsert individual records in an existing leaderboard 15 | - Re-populate the leaderboard with asynchrony and atomicity 16 | - Build mini-leaderboards contained in simple elixir structs 17 | - Add your own custom storage engine (`CxLeaderboard.Storage` behaviour) 18 | 19 | Here's a quick example. We use the negative score values trick to make higher 20 | score sort to the top naturally. 21 | 22 | alias CxLeaderboard.Leaderboard 23 | 24 | my_lb = Leaderboard.create!(name: :main) 25 | my_lb = 26 | Leaderboard.populate!(my_lb, [ 27 | {{-50, :id1}, :alice}, 28 | {{-40, :id2}, :bob} 29 | ]) 30 | 31 | my_lb 32 | |> Leaderboard.top() 33 | |> Enum.take(2) 34 | # [ 35 | # {{-50, :id1}, :alice, {0, {1, 99.0}}}, 36 | # {{-40, :id2}, :bob, {1, {2, 50.0}}} 37 | # ] 38 | 39 | ## Entry 40 | 41 | An entry is a structure that you populate into the leaderboard. The shape of 42 | an entry can be one of the following: 43 | 44 | * `{score, id}` 45 | * `{score, tiebreaker, id}` 46 | * `{{score, id}, payload}` 47 | * `{{score, tiebreaker, id}, payload}` 48 | 49 | A `score` can be any term — it will be used for sorting and ranking. 50 | 51 | A `tiebreaker` (also any term) comes in handy when you know that you will have 52 | multiple records of the same rank, and you'd like to use additional criteria 53 | to sort them in the leaderboard. 54 | 55 | An `id` is any term that uniquely identifies a record, and that you will be 56 | using to `get` them. Id is always the last tiebreaker. 57 | 58 | A `payload` is any term that you'd like to store with your record. Use it for 59 | everything you need to display the leaderboard. If not provided, `id` will be 60 | used as the payload. 61 | 62 | ## Record 63 | 64 | A record is what you get back when querying the leaderboard. It contains both 65 | your entry, and calculated stats. Here's what it looks like: 66 | 67 | # without tiebreaker 68 | {{score, id}, payload, {index, {rank, percentile}}} 69 | #\\___key___/ \\payload/ \\entry stats/ \\___rank stats___/ 70 | 71 | # with tiebreaker 72 | {{score, tiebreaker, id}, payload, {index, {rank, percentile}}} 73 | #\\_________key_________/ \\payload/ \\entry stats/ \\___rank stats___/ 74 | 75 | ## Stats 76 | 77 | By default the stats you get are index, rank, and percentile. However, passing 78 | a custom indexer into the `create/1` or `client_for/2` functions allows you to 79 | calculate your own stats. To learn more about indexer customization read the 80 | module docs of `CxLeaderboard.Indexer`. 81 | """ 82 | 83 | @enforce_keys [:state, :store, :indexer] 84 | defstruct [:state, :store, :indexer, :data] 85 | 86 | alias CxLeaderboard.{Leaderboard, Entry, Record, Indexer} 87 | 88 | @type t :: %__MODULE__{ 89 | state: state(), 90 | store: module(), 91 | indexer: Indexer.t(), 92 | data: Enumerable.t() | nil 93 | } 94 | @type state :: term 95 | 96 | ## Writer functions 97 | 98 | @doc """ 99 | Creates a new leaderboard. 100 | 101 | ## Options 102 | 103 | * `:store` - storage engine to use for the leaderboard. Supports 104 | `CxLeaderboard.EtsStore` and `CxLeaderboard.TermStore`. Default: 105 | `CxLeaderboard.EtsStore`. 106 | 107 | * `:indexer` - indexer to use for stats calculation. The default indexer 108 | calculates rank with offsets (e.g. 1,1,3) and percentile based on same-or- 109 | lower scores, within 1-99 range. Learn more about making custom indexers 110 | in `CxLeaderboard.Indexer` module doc. 111 | 112 | * `:name` - sets the name identifying the leaderboard. Only needed when 113 | using `CxLeaderboard.EtsStore`. 114 | 115 | ## Examples 116 | 117 | iex> Leaderboard.create(name: :global) 118 | {:ok, 119 | %Leaderboard{ 120 | state: :global, 121 | store: CxLeaderboard.EtsStore, 122 | indexer: %CxLeaderboard.Indexer{}, 123 | data: [] 124 | } 125 | } 126 | """ 127 | @spec create(keyword()) :: {:ok, Leaderboard.t()} | {:error, term} 128 | def create(kwargs \\ []) do 129 | {store, indexer, data} = parse_options(kwargs) 130 | 131 | case store.create(kwargs) do 132 | {:ok, state} -> 133 | {:ok, 134 | %__MODULE__{state: state, store: store, indexer: indexer, data: data}} 135 | 136 | error -> 137 | error 138 | end 139 | end 140 | 141 | @doc """ 142 | Same as `create/1` but returns the leaderboard or raises an error. 143 | """ 144 | @spec create!(keyword()) :: Leaderboard.t() 145 | def create!(kwargs \\ []) do 146 | {:ok, board} = create(kwargs) 147 | board 148 | end 149 | 150 | @doc """ 151 | Clears the data from a leaderboard. 152 | 153 | ## Examples 154 | 155 | iex> {:ok, board} = Leaderboard.create(name: :foo) 156 | iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}]) 157 | iex> Leaderboard.count(board) 158 | 2 159 | iex> {:ok, board} = Leaderboard.clear(board) 160 | iex> Leaderboard.count(board) 161 | 0 162 | """ 163 | @spec clear(Leaderboard.t()) :: {:ok, Leaderboard.t()} | {:error, term} 164 | def clear(lb = %__MODULE__{state: state, store: store}) do 165 | state 166 | |> store.clear() 167 | |> update_state(lb) 168 | end 169 | 170 | @doc """ 171 | Same as `clear/1` but returns the leaderboard or raises. 172 | """ 173 | @spec clear!(Leaderboard.t()) :: Leaderboard.t() 174 | def clear!(lb) do 175 | {:ok, lb} = clear(lb) 176 | lb 177 | end 178 | 179 | @doc """ 180 | Populates a leaderboard with entries replacing any existing content. Invalid 181 | entries are silently skipped. 182 | 183 | See Entry section of the module doc for information about entries. 184 | 185 | ## Examples 186 | 187 | iex> {:ok, board} = Leaderboard.create(name: :foo) 188 | iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}]) 189 | iex> Leaderboard.count(board) 190 | 2 191 | """ 192 | @spec populate(Leaderboard.t(), Enumerable.t()) :: 193 | {:ok, Leaderboard.t()} | {:error, term} 194 | def populate( 195 | lb = %__MODULE__{state: state, store: store, indexer: indexer}, 196 | data 197 | ) do 198 | state 199 | |> store.populate(build_data_stream(data), indexer) 200 | |> update_state(lb) 201 | end 202 | 203 | @doc """ 204 | Same as `populate/2` but returns the leaderboard or raises. 205 | """ 206 | @spec populate!(Leaderboard.t(), Enumerable.t()) :: Leaderboard.t() 207 | def populate!(lb, data) do 208 | {:ok, lb} = populate(lb, build_data_stream(data)) 209 | lb 210 | end 211 | 212 | @doc """ 213 | Populates a leaderboard with entries asynchronously. Only works with 214 | `CxLeaderboard.EtsStore`. Invalid entries are silently skipped. 215 | 216 | See Entry section of the module doc for information about entries. 217 | 218 | ## Examples 219 | 220 | iex> {:ok, board} = Leaderboard.create(name: :foo) 221 | iex> {:ok, board} = Leaderboard.async_populate(board, [ 222 | ...> {-2, :id1}, 223 | ...> {-3, :id2} 224 | ...> ]) 225 | iex> Leaderboard.count(board) 226 | 0 227 | iex> :timer.sleep(100) 228 | iex> Leaderboard.count(board) 229 | 2 230 | """ 231 | @spec async_populate(Leaderboard.t(), Enumerable.t()) :: 232 | {:ok, Leaderboard.t()} | {:error, term} 233 | def async_populate( 234 | lb = %__MODULE__{state: state, store: store, indexer: indexer}, 235 | data 236 | ) do 237 | state 238 | |> store.async_populate(build_data_stream(data), indexer) 239 | |> update_state(lb) 240 | end 241 | 242 | @doc """ 243 | Same as `async_populate/2` but returns the leaderboard or raises. 244 | """ 245 | @spec async_populate!(Leaderboard.t(), Enumerable.t()) :: Leaderboard.t() 246 | def async_populate!(lb, data) do 247 | {:ok, _} = async_populate(lb, build_data_stream(data)) 248 | lb 249 | end 250 | 251 | @doc """ 252 | Adds a single entry to an existing leaderboard. Invalid entries will return an 253 | error. If the id already exists, will return an error. 254 | 255 | See Entry section of the module doc for information about entries. 256 | 257 | ## Examples 258 | 259 | iex> {:ok, board} = Leaderboard.create(name: :foo) 260 | iex> {:ok, board} = Leaderboard.add(board, {-1, :id1}) 261 | iex> Leaderboard.count(board) 262 | 1 263 | iex> Leaderboard.add(board, :invalid_entry) 264 | {:error, :bad_entry} 265 | iex> Leaderboard.add(board, {-1, :id1}) 266 | {:error, :entry_already_exists} 267 | """ 268 | @spec add(Leaderboard.t(), Entry.t()) :: 269 | {:ok, Leaderboard.t()} | {:error, term} 270 | def add( 271 | lb = %__MODULE__{state: state, store: store, indexer: indexer}, 272 | entry 273 | ) do 274 | case Entry.format(entry) do 275 | error = {:error, _} -> 276 | error 277 | 278 | entry -> 279 | state 280 | |> store.add(entry, indexer) 281 | |> update_state(lb) 282 | end 283 | end 284 | 285 | @doc """ 286 | Same as `add/2` but returns the leaderboard or raises. 287 | """ 288 | @spec add!(Leaderboard.t(), Entry.t()) :: Leaderboard.t() 289 | def add!(lb, entry) do 290 | {:ok, lb} = add(lb, entry) 291 | lb 292 | end 293 | 294 | @doc """ 295 | Updates a single entry in an existing leaderboard. Invalid entries will return 296 | an error. If the id is not in the leaderboard, will return an error. 297 | 298 | See Entry section of the module doc for information about entries. 299 | 300 | ## Examples 301 | 302 | iex> {:ok, board} = Leaderboard.create(name: :foo) 303 | iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}]) 304 | iex> {:ok, board} = Leaderboard.update(board, {-5, :id1}) 305 | iex> Leaderboard.get(board, :id1) 306 | {{-5, :id1}, :id1, {0, {1, 99.0}}} 307 | iex> Leaderboard.update(board, {-2, :missing_id}) 308 | {:error, :entry_not_found} 309 | """ 310 | @spec update(Leaderboard.t(), Entry.t()) :: 311 | {:ok, Leaderboard.t()} | {:error, term} 312 | def update( 313 | lb = %__MODULE__{state: state, store: store, indexer: indexer}, 314 | entry 315 | ) do 316 | case Entry.format(entry) do 317 | error = {:error, _} -> 318 | error 319 | 320 | entry -> 321 | state 322 | |> store.update(entry, indexer) 323 | |> update_state(lb) 324 | end 325 | end 326 | 327 | @doc """ 328 | Same as `update/2` but returns the leaderboard or raises. 329 | """ 330 | @spec update!(Leaderboard.t(), Entry.t()) :: Leaderboard.t() 331 | def update!(lb, entry) do 332 | {:ok, lb} = update(lb, entry) 333 | lb 334 | end 335 | 336 | @doc """ 337 | Updates an entry in an existing leaderboard, or adds it if the id doesn't 338 | exist. Invalid entries will return an error. 339 | 340 | See Entry section of the module doc for information about entries. 341 | 342 | ## Examples 343 | 344 | iex> {:ok, board} = Leaderboard.create(name: :foo) 345 | iex> {:ok, board} = Leaderboard.add_or_update(board, {1, :id1}) 346 | iex> Leaderboard.get(board, :id1) 347 | {{1, :id1}, :id1, {0, {1, 99.0}}} 348 | iex> {:ok, board} = Leaderboard.add_or_update(board, {2, :id1}) 349 | iex> Leaderboard.get(board, :id1) 350 | {{2, :id1}, :id1, {0, {1, 99.0}}} 351 | """ 352 | @spec add_or_update(Leaderboard.t(), Entry.t()) :: 353 | {:ok, Leaderboard.t()} | {:error, term} 354 | def add_or_update( 355 | lb = %__MODULE__{state: state, store: store, indexer: indexer}, 356 | entry 357 | ) do 358 | case Entry.format(entry) do 359 | error = {:error, _} -> 360 | error 361 | 362 | entry -> 363 | state 364 | |> store.add_or_update(entry, indexer) 365 | |> update_state(lb) 366 | end 367 | end 368 | 369 | @doc """ 370 | Same as `add_or_update/2` but returns the leaderboard or raises. 371 | """ 372 | @spec add_or_update!(Leaderboard.t(), Entry.t()) :: Leaderboard.t() 373 | def add_or_update!(lb, entry) do 374 | {:ok, lb} = add_or_update(lb, entry) 375 | lb 376 | end 377 | 378 | @doc """ 379 | Removes an entry from a leaderboard by id. If the id is not in the 380 | leaderboard, will return an error. 381 | 382 | ## Examples 383 | 384 | iex> {:ok, board} = Leaderboard.create(name: :foo) 385 | iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}]) 386 | iex> {:ok, board} = Leaderboard.remove(board, :id1) 387 | iex> Leaderboard.count(board) 388 | 1 389 | iex> Leaderboard.remove(board, :id1) 390 | {:error, :entry_not_found} 391 | """ 392 | @spec remove(Leaderboard.t(), Entry.id()) :: 393 | {:ok, Leaderboard.t()} | {:error, term} 394 | def remove( 395 | lb = %__MODULE__{state: state, store: store, indexer: indexer}, 396 | entry_id 397 | ) do 398 | state 399 | |> store.remove(entry_id, indexer) 400 | |> update_state(lb) 401 | end 402 | 403 | @doc """ 404 | Same as `remove/2` but returns the leaderboard or raises. 405 | """ 406 | @spec remove!(Leaderboard.t(), Entry.id()) :: Leaderboard.t() 407 | def remove!(lb, entry_id) do 408 | {:ok, lb} = remove(lb, entry_id) 409 | lb 410 | end 411 | 412 | ## Reader functions 413 | 414 | @doc """ 415 | Returns a stream of top leaderboard records. 416 | 417 | ## Examples 418 | 419 | iex> {:ok, board} = Leaderboard.create(name: :foo) 420 | iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}]) 421 | iex> Leaderboard.top(board) |> Enum.take(1) 422 | [{{-3, :id2}, :id2, {0, {1, 99.0}}}] 423 | """ 424 | @spec top(Leaderboard.t()) :: Enumerable.t() 425 | def top(%__MODULE__{state: state, store: store}) do 426 | store.top(state) 427 | end 428 | 429 | @doc """ 430 | Returns a stream of bottom leaderboard records. 431 | 432 | ## Examples 433 | 434 | iex> {:ok, board} = Leaderboard.create(name: :foo) 435 | iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}]) 436 | iex> Leaderboard.bottom(board) |> Enum.take(1) 437 | [{{-2, :id1}, :id1, {1, {2, 50.0}}}] 438 | """ 439 | @spec bottom(Leaderboard.t()) :: Enumerable.t() 440 | def bottom(%__MODULE__{state: state, store: store}) do 441 | store.bottom(state) 442 | end 443 | 444 | @doc """ 445 | Returns the number of records in a leaderboard. This number is stored in the 446 | leaderboard, so this is an O(1) operation. 447 | 448 | ## Examples 449 | 450 | iex> {:ok, board} = Leaderboard.create(name: :foo) 451 | iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}]) 452 | iex> Leaderboard.count(board) 453 | 2 454 | """ 455 | @spec count(Leaderboard.t()) :: non_neg_integer 456 | def count(%__MODULE__{state: state, store: store}) do 457 | store.count(state) 458 | end 459 | 460 | @doc """ 461 | Retrieves a single record from a leaderboard by id. Returns `nil` if record is 462 | not found. 463 | 464 | ## Examples 465 | 466 | iex> {:ok, board} = Leaderboard.create(name: :foo) 467 | iex> {:ok, board} = Leaderboard.populate(board, [{-2, :id1}, {-3, :id2}]) 468 | iex> Leaderboard.get(board, :id1) 469 | {{-2, :id1}, :id1, {1, {2, 50.0}}} 470 | iex> Leaderboard.get(board, :missing_id) 471 | nil 472 | """ 473 | @spec get(Leaderboard.t(), Entry.id()) :: Record.t() 474 | def get(%__MODULE__{state: state, store: store}, entry_id) do 475 | store.get(state, entry_id) 476 | end 477 | 478 | @doc """ 479 | Retrieves a range of records from a leaderboard around the given id. Returns 480 | an empty list if the requested record is not found. If the range goes out of 481 | leaderboard bounds will stop at the top/bottom without error. If the given 482 | range is in reverse direction, returns entries in reverse direction as well. 483 | 484 | ## Examples 485 | 486 | iex> {:ok, board} = Leaderboard.create(name: :foo) 487 | iex> {:ok, board} = Leaderboard.populate(board, [ 488 | ...> {-4, :id1}, 489 | ...> {-3, :id2}, 490 | ...> {-2, :id3}, 491 | ...> {-1, :id4} 492 | ...> ]) 493 | iex> Leaderboard.get(board, :id3, -1..0) 494 | [ 495 | {{-3, :id2}, :id2, {1, {2, 74.5}}}, 496 | {{-2, :id3}, :id3, {2, {3, 50.0}}} 497 | ] 498 | iex> Leaderboard.get(board, :id3, 0..-1) 499 | [ 500 | {{-2, :id3}, :id3, {2, {3, 50.0}}}, 501 | {{-3, :id2}, :id2, {1, {2, 74.5}}} 502 | ] 503 | """ 504 | @spec get(Leaderboard.t(), Entry.id(), Range.t()) :: [Record.t()] 505 | def get(%__MODULE__{state: state, store: store}, entry_id, range) do 506 | store.get(state, entry_id, range) 507 | end 508 | 509 | @doc """ 510 | If your chosen storage engine supports server/client operation 511 | (`CxLeaderboard.EtsStore` does), then you could set `Leaderboard` as a worker 512 | in your application's children list. For each leaderboard you would just add a 513 | worker, passing it a name. Then in your applicaiton you can use `client_for/2` 514 | to get the reference to it that you can use to call all the functions in this 515 | module. 516 | 517 | ## Examples 518 | 519 | defmodule Foo.Application do 520 | use Application 521 | 522 | def start(_type, _args) do 523 | import Supervisor.Spec 524 | 525 | # Make sure your data is available as a stream 526 | data_stream = Foo.LeaderboardData.stream() 527 | 528 | children = [ 529 | worker(CxLeaderboard.Leaderboard, [ 530 | name: :global, 531 | data: data_stream 532 | ]) 533 | ] 534 | 535 | opts = [strategy: :one_for_one, name: Foo.Supervisor] 536 | Supervisor.start_link(children, opts) 537 | end 538 | end 539 | 540 | # Elsewhere in your application 541 | alias CxLeaderboard.Leaderboard 542 | 543 | global_lb = Leaderboard.client_for(:global) 544 | global_lb 545 | |> Leaderboard.top() 546 | |> Enum.take(10) 547 | 548 | Indexer is configured at the client level (it's passed to server with each 549 | function), therefore if you want the leaderboard to use a custom indexer, all 550 | you need to do is: 551 | 552 | lb = Leaderboard.client_for(:global, indexer: my_custom_indexer) 553 | 554 | See the Stats section of this module's doc to learn more about indexers. 555 | """ 556 | @spec start_link(atom(), keyword()) :: GenServer.on_start() 557 | def start_link(name, kwargs) do 558 | {store, indexer, data} = parse_options(kwargs) 559 | lb = %__MODULE__{state: name, store: store, indexer: indexer, data: data} 560 | store.start_link(lb) 561 | end 562 | 563 | @doc """ 564 | When your leaderboard is started as a server elsewhere, use this function to 565 | get a reference to be able to interact with it. See docs for `start_link/2` 566 | for more information on client/server mode of operation. 567 | """ 568 | @spec client_for(atom(), module()) :: Leaderboard.t() 569 | def client_for(name, store \\ CxLeaderboard.EtsStore) do 570 | store.get_lb(name) 571 | end 572 | 573 | ## Private 574 | 575 | defp parse_options(kwargs) do 576 | store = Keyword.get(kwargs, :store, CxLeaderboard.EtsStore) 577 | indexer = Keyword.get(kwargs, :indexer, %Indexer{}) 578 | 579 | data = 580 | case Keyword.get(kwargs, :data, []) do 581 | [] -> [] 582 | data -> build_data_stream(data) 583 | end 584 | 585 | {store, indexer, data} 586 | end 587 | 588 | defp update_state({:ok, state}, lb), do: {:ok, Map.put(lb, :state, state)} 589 | defp update_state(error, _), do: error 590 | 591 | defp build_data_stream(data) do 592 | data 593 | |> Stream.map(&Entry.format/1) 594 | |> Stream.reject(fn 595 | {:error, _} -> true 596 | _ -> false 597 | end) 598 | end 599 | end 600 | -------------------------------------------------------------------------------- /lib/cx_leaderboard/record.ex: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard.Record do 2 | @moduledoc """ 3 | Record is the tuple-based structure that you get back when querying a 4 | leaderboard. In addition to the stored entry it also carries stats like rank 5 | and percentile. 6 | """ 7 | 8 | alias CxLeaderboard.Entry 9 | 10 | @typedoc """ 11 | This is how each record comes back to you from the leaderboard. See below for 12 | breakdowns of each type. 13 | """ 14 | @type t :: {Entry.key(), Entry.payload(), {entry_stats(), rank_stats()}} 15 | 16 | @typedoc """ 17 | Any entry stats returned by the indexer. 18 | """ 19 | @type entry_stats :: term 20 | 21 | @typedoc """ 22 | Any rank stats returned by the indexer 23 | """ 24 | @type rank_stats :: term 25 | 26 | def get_entry({key, payload, _}), do: {key, payload} 27 | def get_stats({_, _, stats}), do: stats 28 | end 29 | -------------------------------------------------------------------------------- /lib/cx_leaderboard/storage.ex: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard.Storage do 2 | @moduledoc """ 3 | Use this behaviour to implement your own leaderboard storage engines. 4 | """ 5 | 6 | alias CxLeaderboard.{Leaderboard, Entry, Record, Indexer} 7 | 8 | @doc """ 9 | Create a leaderboard in your storage (keyword arguments are determined by 10 | storage needs). Return what needs to be persisted. 11 | """ 12 | @callback create(keyword()) :: {:ok, Leaderboard.state()} | {:error, term} 13 | 14 | @doc """ 15 | If supporting server/client mode: add a way to start the server and make sure 16 | to store the leaderboard struct in the state, since it will be needed for 17 | get_lb. 18 | """ 19 | @callback start_link(Leaderboard.t()) :: GenServer.on_start() 20 | 21 | @doc """ 22 | If supporting server/client mode: add a way to fetch the leaderboard stored in 23 | your GenServer's state. 24 | """ 25 | @callback get_lb(atom()) :: Leaderboard.t() 26 | 27 | @doc """ 28 | Clear all the data in your leaderboard state. 29 | """ 30 | @callback clear(Leaderboard.state()) :: 31 | {:ok, Leaderboard.state()} | {:error, term} 32 | 33 | @doc """ 34 | Replace all data in the leaderboard with the data in the provided stream. 35 | Block until completed. 36 | """ 37 | @callback populate(Leaderboard.state(), Enumerable.t(), Indexer.t()) :: 38 | {:ok, Leaderboard.state()} | {:error, term} 39 | 40 | @doc """ 41 | Replace all data in the leaderboard with the data in the provided stream. 42 | Return immediately, perform most of the work asynchronously. 43 | """ 44 | @callback async_populate(Leaderboard.state(), Enumerable.t(), Indexer.t()) :: 45 | {:ok, term} | {:error, term} 46 | 47 | @doc """ 48 | Add a single entry to the leaderboard. Return an error if the entry is already 49 | in the leaderboard. The operation should be blocking. 50 | """ 51 | @callback add(Leaderboard.state(), Entry.t(), Indexer.t()) :: 52 | {:ok, Leaderboard.state()} | {:error, term} 53 | 54 | @doc """ 55 | Remove a single entry from the leaderboard based on its id. Return an error if 56 | the id does not exist. The operation should be blocking. 57 | """ 58 | @callback remove(Leaderboard.state(), Entry.id(), Indexer.t()) :: 59 | {:ok, Leaderboard.state()} | {:error, term} 60 | 61 | @doc """ 62 | Update a single entry in the leaderboard. Return an error if the entry is not 63 | found in the leaderboard. The operation should be blocking. 64 | """ 65 | @callback update(Leaderboard.state(), Entry.t(), Indexer.t()) :: 66 | {:ok, Leaderboard.state()} | {:error, term} 67 | 68 | @doc """ 69 | Atomically insert an entry, or update it if its id already exists in the 70 | leaderboard. 71 | """ 72 | @callback add_or_update(Leaderboard.state(), Entry.t(), Indexer.t()) :: 73 | {:ok, Leaderboard.state()} | {:error, term} 74 | 75 | @doc """ 76 | Return a leaderboard record by its id. Return nil if not found. 77 | """ 78 | @callback get(Leaderboard.state(), Entry.id()) :: Record.t() | nil 79 | 80 | @doc """ 81 | Return a list of records around the given id. The list should go from top to 82 | bottom if the range is increasing, and from bottom to top if range decreasing. 83 | Zero always corresponds to where the id is positioned. 84 | 85 | For example: 86 | 87 | - A range -2..1 should return (from top to bottom) 2 records prior to the 88 | given id, the record at the given id, and 1 record after the given id. 89 | - A range 2..-2 should return (from bottom to top) 2 records after the given 90 | id, the record at the given id, and 2 records before the given id. 91 | """ 92 | @callback get(Leaderboard.state(), Entry.id(), Range.t()) :: [Record.t()] 93 | 94 | @doc """ 95 | Return a correctly ordered stream of top leaderboard records that can be 96 | accessed all the way to the bottom. 97 | """ 98 | @callback top(Leaderboard.state()) :: Enumerable.t() 99 | 100 | @doc """ 101 | Return a correctly ordered stream of bottom leaderboard records that can be 102 | accessed all the way to the top. 103 | """ 104 | @callback bottom(Leaderboard.state()) :: Enumerable.t() 105 | 106 | @doc """ 107 | Show the number of records in the leaderboard. 108 | """ 109 | @callback count(Leaderboard.state()) :: non_neg_integer 110 | 111 | @optional_callbacks async_populate: 3, start_link: 1, get_lb: 1 112 | end 113 | -------------------------------------------------------------------------------- /lib/cx_leaderboard/term_store.ex: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard.TermStore do 2 | @moduledoc """ 3 | Use this storage engine to make small size one-off leaderboards that are 4 | stored entirely in a variable. Useful for leaderboards scoped to small groups 5 | of participants. 6 | """ 7 | 8 | @behaviour CxLeaderboard.Storage 9 | 10 | alias CxLeaderboard.{Indexer, Entry} 11 | 12 | ## Writers 13 | 14 | def create(_) do 15 | {:ok, %{table: [], index: %{}, count: 0}} 16 | end 17 | 18 | def clear(_) do 19 | {:ok, %{table: [], index: %{}, count: 0}} 20 | end 21 | 22 | def populate(_, data, indexer) do 23 | table = Enum.sort(data) 24 | count = Enum.count(data) 25 | index = build_index(table, count, indexer) 26 | {:ok, %{table: table, index: index, count: count}} 27 | end 28 | 29 | def add(state = %{table: table, count: count}, entry, indexer) do 30 | id = Entry.get_id(entry) 31 | 32 | if get(state, id) do 33 | {:error, :entry_already_exists} 34 | else 35 | table = Enum.sort([entry | table]) 36 | count = count + 1 37 | index = build_index(table, count, indexer) 38 | {:ok, %{table: table, index: index, count: count}} 39 | end 40 | end 41 | 42 | def remove( 43 | state = %{ 44 | table: table, 45 | index: index, 46 | count: count 47 | }, 48 | id, 49 | indexer 50 | ) do 51 | if get(state, id) do 52 | {_, key, _} = index[id] 53 | table = List.keydelete(table, key, 0) 54 | count = count - 1 55 | index = build_index(table, count, indexer) 56 | {:ok, %{table: table, index: index, count: count}} 57 | else 58 | {:error, :entry_not_found} 59 | end 60 | end 61 | 62 | def update( 63 | state = %{table: table, index: index, count: count}, 64 | entry, 65 | indexer 66 | ) do 67 | id = Entry.get_id(entry) 68 | 69 | if get(state, id) do 70 | {_, key, _} = index[id] 71 | table = Enum.sort([entry | List.keydelete(table, key, 0)]) 72 | index = build_index(table, count, indexer) 73 | {:ok, %{table: table, index: index}} 74 | else 75 | {:error, :entry_not_found} 76 | end 77 | end 78 | 79 | def add_or_update(state, entry, indexer) do 80 | id = Entry.get_id(entry) 81 | 82 | case get(state, id) do 83 | nil -> add(state, entry, indexer) 84 | _ -> update(state, entry, indexer) 85 | end 86 | end 87 | 88 | ## Readers 89 | 90 | def get(%{table: table, index: index}, id) do 91 | with {_, key, stats} <- index[id], 92 | {_, payload} <- List.keyfind(table, key, 0) do 93 | {key, payload, stats} 94 | else 95 | _ -> nil 96 | end 97 | end 98 | 99 | def get(state = %{table: table}, id, start..finish) do 100 | case get(state, id) do 101 | nil -> 102 | [] 103 | 104 | {key, _, _} -> 105 | key_index = 106 | table 107 | |> Enum.find_index(fn 108 | {^key, _} -> true 109 | _ -> false 110 | end) 111 | 112 | {min, max} = Enum.min_max([start, finish]) 113 | 114 | min_index = Enum.max([key_index + min, 0]) 115 | max_index = Enum.max([key_index + max, 0]) 116 | 117 | slice = 118 | table 119 | |> Enum.slice(min_index..max_index) 120 | |> Enum.map(fn entry -> get(state, Entry.get_id(entry)) end) 121 | 122 | if finish < start, do: Enum.reverse(slice), else: slice 123 | end 124 | end 125 | 126 | def top(state = %{table: table}) do 127 | table 128 | |> Stream.map(fn entry -> 129 | id = Entry.get_id(entry) 130 | get(state, id) 131 | end) 132 | end 133 | 134 | def bottom(state = %{table: table}) do 135 | table 136 | |> Enum.reverse() 137 | |> Stream.map(fn entry -> 138 | id = Entry.get_id(entry) 139 | get(state, id) 140 | end) 141 | end 142 | 143 | def count(%{count: count}) do 144 | count 145 | end 146 | 147 | ## Private 148 | 149 | defp build_index(table, count, indexer) do 150 | table 151 | |> Stream.map(fn {key, _} -> key end) 152 | |> Indexer.index(count, indexer) 153 | |> Stream.map(fn term = {id, _, _} -> {id, term} end) 154 | |> Enum.into(%{}) 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | description: "Fast, customizable leaderboards database.", 7 | app: :cx_leaderboard, 8 | version: "0.1.0", 9 | elixir: "~> 1.5", 10 | start_permanent: Mix.env() == :prod, 11 | package: package(), 12 | deps: deps(), 13 | source_url: "https://github.com/crossfield/cx_leaderboard", 14 | dialyzer: [flags: ["-Wunmatched_returns", :error_handling, :underspecs]], 15 | docs: [main: "README", extras: ["README.md"]] 16 | ] 17 | end 18 | 19 | # Run "mix help compile.app" to learn about applications. 20 | def application do 21 | [ 22 | extra_applications: [:logger] 23 | ] 24 | end 25 | 26 | defp package do 27 | %{ 28 | licenses: ["Apache 2"], 29 | maintainers: ["Max Chernyak"], 30 | links: %{"GitHub" => "https://github.com/crossfield/cx_leaderboard"}, 31 | files: 32 | ~w(lib .formatter.exs CODE_OF_CONDUCT.md LICENSE mix.exs README.md) 33 | } 34 | end 35 | 36 | # Run "mix help deps" to learn about dependencies. 37 | defp deps do 38 | [ 39 | {:dialyxir, "~> 0.5", only: :dev, runtime: false}, 40 | {:benchee, "~> 0.12", only: :dev, runtime: false}, 41 | {:ex_doc, "~> 0.18", only: :dev, runtime: false} 42 | ] 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "0.12.1", "1286a79bab2f1899220134daf9a695586af00ea71968e6cd89d3d3afdf7cecba", [:mix], [{:deep_merge, "~> 0.1", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "deep_merge": {:hex, :deep_merge, "0.1.1", "c27866a7524a337b6a039eeb8dd4f17d458fd40fbbcb8c54661b71a22fffe846", [:mix], [], "hexpm"}, 4 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 5 | "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm"}, 6 | "ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 7 | } 8 | -------------------------------------------------------------------------------- /test/cx_leaderboard_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboardTest do 2 | use ExUnit.Case 3 | alias CxLeaderboard.Leaderboard 4 | doctest Leaderboard 5 | end 6 | -------------------------------------------------------------------------------- /test/ets_store_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EtsStoreTest do 2 | use CxLeaderboard.StorageCase 3 | alias CxLeaderboard.{Leaderboard, EtsStore} 4 | 5 | setup do 6 | board = Leaderboard.create!(name: :test_board, store: EtsStore) 7 | {:ok, board: board} 8 | end 9 | 10 | test "supports storing data source", %{} do 11 | data = [{-10, :id1}, {-20, :id2}] 12 | 13 | {:ok, _} = Leaderboard.start_link(:global_board, data: data) 14 | client = Leaderboard.client_for(:global_board) 15 | 16 | top = Leaderboard.top(client) |> Enum.to_list() 17 | 18 | assert [ 19 | {{-20, :id2}, :id2, {0, {1, 99.0}}}, 20 | {{-10, :id1}, :id1, {1, {2, 50.0}}} 21 | ] == top 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/storage_case.exs: -------------------------------------------------------------------------------- 1 | defmodule CxLeaderboard.StorageCase do 2 | use ExUnit.CaseTemplate 3 | alias CxLeaderboard.{Leaderboard, Indexer} 4 | alias CxLeaderboard.Indexer.Stats 5 | 6 | using do 7 | quote location: :keep do 8 | test "keeps entry count", %{board: board} do 9 | board = 10 | board 11 | |> Leaderboard.populate!([ 12 | {-20, :id1}, 13 | {-30, :id2} 14 | ]) 15 | 16 | assert 2 == Leaderboard.count(board) 17 | end 18 | 19 | test "returns top entries", %{board: board} do 20 | top = 21 | board 22 | |> Leaderboard.populate!([ 23 | {-20, :id1}, 24 | {-30, :id2} 25 | ]) 26 | |> Leaderboard.top() 27 | |> Enum.take(2) 28 | 29 | assert [ 30 | {{-30, :id2}, :id2, {0, {1, 99.0}}}, 31 | {{-20, :id1}, :id1, {1, {2, 50.0}}} 32 | ] == top 33 | end 34 | 35 | test "returns bottom entries", %{board: board} do 36 | bottom = 37 | board 38 | |> Leaderboard.populate!([ 39 | {-20, :id1}, 40 | {-30, :id2} 41 | ]) 42 | |> Leaderboard.bottom() 43 | |> Enum.take(2) 44 | 45 | assert [ 46 | {{-20, :id1}, :id1, {1, {2, 50.0}}}, 47 | {{-30, :id2}, :id2, {0, {1, 99.0}}} 48 | ] == bottom 49 | end 50 | 51 | test "supports payloads in each entry", %{board: board} do 52 | top = 53 | board 54 | |> Leaderboard.populate!([ 55 | {{-20, :id1}, %{foo: "foo"}}, 56 | {{-30, :id2}, %{bar: "bar"}} 57 | ]) 58 | |> Leaderboard.top() 59 | |> Enum.take(2) 60 | 61 | assert [ 62 | {{-30, :id2}, %{bar: "bar"}, {0, {1, 99.0}}}, 63 | {{-20, :id1}, %{foo: "foo"}, {1, {2, 50.0}}} 64 | ] == top 65 | end 66 | 67 | test "supports tiebreaks in each entry", %{board: board} do 68 | top = 69 | board 70 | |> Leaderboard.populate!([ 71 | {-20, 2, :id1}, 72 | {-20, 1, :id2}, 73 | {-30, 3, :id3}, 74 | {-30, 4, :id4} 75 | ]) 76 | |> Leaderboard.top() 77 | |> Enum.take(4) 78 | 79 | assert [ 80 | {{-30, 3, :id3}, :id3, {0, {1, 99.0}}}, 81 | {{-30, 4, :id4}, :id4, {1, {1, 99.0}}}, 82 | {{-20, 1, :id2}, :id2, {2, {3, 50.0}}}, 83 | {{-20, 2, :id1}, :id1, {3, {3, 50.0}}} 84 | ] == top 85 | end 86 | 87 | test "supports adding individual entries", %{board: board} do 88 | top = 89 | board 90 | |> Leaderboard.populate!([{-20, :id1}, {-30, :id2}]) 91 | |> Leaderboard.add!({-40, :id3}) 92 | |> Leaderboard.add!({-40, :id4}) 93 | |> Leaderboard.top() 94 | |> Enum.take(4) 95 | 96 | assert [ 97 | {{-40, :id3}, :id3, {0, {1, 99.0}}}, 98 | {{-40, :id4}, :id4, {1, {1, 99.0}}}, 99 | {{-30, :id2}, :id2, {2, {3, 50.0}}}, 100 | {{-20, :id1}, :id1, {3, {4, 25.5}}} 101 | ] == top 102 | end 103 | 104 | test "errors on adding a duplicate id", %{board: board} do 105 | assert {:error, :entry_already_exists} == 106 | board 107 | |> Leaderboard.add!({1, :id1}) 108 | |> Leaderboard.add({1, :id1}) 109 | end 110 | 111 | test "supports adding individual entries when empty", %{board: board} do 112 | top = 113 | board 114 | |> Leaderboard.add!({-20, :id1}) 115 | |> Leaderboard.top() 116 | |> Enum.take(1) 117 | 118 | assert [ 119 | {{-20, :id1}, :id1, {0, {1, 99.0}}} 120 | ] == top 121 | end 122 | 123 | test "supports updating individual entries", %{board: board} do 124 | board = 125 | board 126 | |> Leaderboard.populate!([ 127 | {-20, :id1}, 128 | {-30, :id2} 129 | ]) 130 | 131 | top = 132 | board 133 | |> Leaderboard.top() 134 | |> Enum.take(2) 135 | 136 | assert [ 137 | {{-30, :id2}, :id2, {0, {1, 99.0}}}, 138 | {{-20, :id1}, :id1, {1, {2, 50.0}}} 139 | ] == top 140 | 141 | top = 142 | board 143 | |> Leaderboard.update!({-10, :id2}) 144 | |> Leaderboard.top() 145 | |> Enum.take(3) 146 | 147 | assert [ 148 | {{-20, :id1}, :id1, {0, {1, 99.0}}}, 149 | {{-10, :id2}, :id2, {1, {2, 50.0}}} 150 | ] == top 151 | end 152 | 153 | test "errors on updating a missing id", %{board: board} do 154 | assert {:error, :entry_not_found} == 155 | Leaderboard.update(board, {1, :id1}) 156 | end 157 | 158 | test "supports removing individual entries", %{board: board} do 159 | top = 160 | board 161 | |> Leaderboard.populate!([{-20, :id1}, {-30, :id2}]) 162 | |> Leaderboard.remove!(:id1) 163 | |> Leaderboard.top() 164 | |> Enum.take(2) 165 | 166 | assert [ 167 | {{-30, :id2}, :id2, {0, {1, 99.0}}} 168 | ] == top 169 | end 170 | 171 | test "errors on removing a missing id", %{board: board} do 172 | assert {:error, :entry_not_found} == 173 | Leaderboard.remove(board, :missing_id) 174 | end 175 | 176 | test "supports atomic add via add_or_update", %{board: board} do 177 | top = 178 | board 179 | |> Leaderboard.add_or_update!({-10, :id1}) 180 | |> Leaderboard.top() 181 | |> Enum.take(1) 182 | 183 | assert [ 184 | {{-10, :id1}, :id1, {0, {1, 99.0}}} 185 | ] == top 186 | end 187 | 188 | test "supports atomic update via add_or_update", %{board: board} do 189 | top = 190 | board 191 | |> Leaderboard.add!({-10, :id1}) 192 | |> Leaderboard.add_or_update!({-20, :id1}) 193 | |> Leaderboard.top() 194 | |> Enum.take(2) 195 | 196 | assert [ 197 | {{-20, :id1}, :id1, {0, {1, 99.0}}} 198 | ] == top 199 | end 200 | 201 | test "gracefully handles invalid entries", %{board: board} do 202 | assert {:error, :bad_entry} = 203 | Leaderboard.add(board, {-20, :tiebreak, :id1, :oops}) 204 | end 205 | 206 | test "ignores invalid entries when populating", %{board: board} do 207 | top = 208 | board 209 | |> Leaderboard.populate!([ 210 | {-20, :tiebreak, :id1, :oops}, 211 | {-30, :tiebreak, :id2} 212 | ]) 213 | |> Leaderboard.top() 214 | |> Enum.take(2) 215 | 216 | assert [ 217 | {{-30, :tiebreak, :id2}, :id2, {0, {1, 99.0}}} 218 | ] == top 219 | end 220 | 221 | test "anything can be a score", %{board: board} do 222 | top = 223 | board 224 | |> Leaderboard.populate!([ 225 | {"a", :id1}, 226 | {"b", :id2} 227 | ]) 228 | |> Leaderboard.top() 229 | |> Enum.take(2) 230 | 231 | assert [ 232 | {{"a", :id1}, :id1, {0, {1, 99.0}}}, 233 | {{"b", :id2}, :id2, {1, {2, 50.0}}} 234 | ] == top 235 | end 236 | 237 | test "retrieves records via get", %{board: board} do 238 | board = 239 | board 240 | |> Leaderboard.populate!([{-20, :id1}, {-30, :id2}]) 241 | 242 | assert {{-20, :id1}, :id1, {1, {2, 50.0}}} == 243 | Leaderboard.get(board, :id1) 244 | end 245 | 246 | test "retrieves next adjacent records", %{board: board} do 247 | records = 248 | board 249 | |> Leaderboard.populate!([ 250 | {-40, :id1}, 251 | {-30, :id2}, 252 | {-20, :id3}, 253 | {-10, :id4} 254 | ]) 255 | |> Leaderboard.get(:id2, 0..1) 256 | 257 | assert [ 258 | {{-30, :id2}, :id2, {1, {2, 74.5}}}, 259 | {{-20, :id3}, :id3, {2, {3, 50.0}}} 260 | ] == records 261 | end 262 | 263 | test "retrieves previous adjacent records", %{board: board} do 264 | records = 265 | board 266 | |> Leaderboard.populate!([ 267 | {-40, :id1}, 268 | {-30, :id2}, 269 | {-20, :id3}, 270 | {-10, :id4} 271 | ]) 272 | |> Leaderboard.get(:id2, -1..0) 273 | 274 | assert [ 275 | {{-40, :id1}, :id1, {0, {1, 99.0}}}, 276 | {{-30, :id2}, :id2, {1, {2, 74.5}}} 277 | ] == records 278 | end 279 | 280 | test "retrieves an adjacent range of records", %{board: board} do 281 | records = 282 | board 283 | |> Leaderboard.populate!([ 284 | {-40, :id1}, 285 | {-30, :id2}, 286 | {-20, :id3}, 287 | {-10, :id4} 288 | ]) 289 | |> Leaderboard.get(:id2, -2..1) 290 | 291 | assert [ 292 | {{-40, :id1}, :id1, {0, {1, 99.0}}}, 293 | {{-30, :id2}, :id2, {1, {2, 74.5}}}, 294 | {{-20, :id3}, :id3, {2, {3, 50.0}}} 295 | ] == records 296 | end 297 | 298 | test "retrieves a range of records in reverse order", %{board: board} do 299 | records = 300 | board 301 | |> Leaderboard.populate!([ 302 | {-40, :id1}, 303 | {-30, :id2}, 304 | {-20, :id3}, 305 | {-10, :id4} 306 | ]) 307 | |> Leaderboard.get(:id2, 2..-1) 308 | 309 | assert [ 310 | {{-10, :id4}, :id4, {3, {4, 25.5}}}, 311 | {{-20, :id3}, :id3, {2, {3, 50.0}}}, 312 | {{-30, :id2}, :id2, {1, {2, 74.5}}}, 313 | {{-40, :id1}, :id1, {0, {1, 99.0}}} 314 | ] == records 315 | end 316 | 317 | test "retrieves an empty list if id is not found", %{board: board} do 318 | records = 319 | board 320 | |> Leaderboard.populate!([ 321 | {-40, :id1}, 322 | {-30, :id2} 323 | ]) 324 | |> Leaderboard.get(:id3, -2..1) 325 | 326 | assert [] == records 327 | end 328 | 329 | test "supports custom indexer", %{board: board} do 330 | custom_indexer = %Indexer{ 331 | on_rank: &Stats.sequential_rank_less_than_percentile/1, 332 | on_entry: fn {i, _, _, _} -> i * 2 end 333 | } 334 | 335 | board = Map.put(board, :indexer, custom_indexer) 336 | 337 | records = 338 | board 339 | |> Leaderboard.populate!([ 340 | {-40, :id1}, 341 | {-30, :id2}, 342 | {-20, :id3}, 343 | {-10, :id4} 344 | ]) 345 | |> Leaderboard.top() 346 | |> Enum.to_list() 347 | 348 | assert [ 349 | {{-40, :id1}, :id1, {0, {1, 75.0}}}, 350 | {{-30, :id2}, :id2, {2, {2, 50.0}}}, 351 | {{-20, :id3}, :id3, {4, {3, 25.0}}}, 352 | {{-10, :id4}, :id4, {6, {4, 0.0}}} 353 | ] == records 354 | end 355 | end 356 | end 357 | end 358 | -------------------------------------------------------------------------------- /test/term_store_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TermStoreTest do 2 | use CxLeaderboard.StorageCase 3 | alias CxLeaderboard.{Leaderboard, TermStore} 4 | 5 | setup do 6 | board = Leaderboard.create!(store: TermStore) 7 | {:ok, board: board} 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | Code.require_file("test/storage_case.exs") 4 | --------------------------------------------------------------------------------