├── rebar.lock ├── doc ├── .gitignore ├── custom_stylesheet.css └── overview.edoc ├── .vimrc ├── test_release.sh ├── .gitignore ├── test ├── etc │ └── sys.config └── aequitas_SUITE.erl ├── rebar.config.script ├── src ├── aequitas.app.src ├── aequitas_app.erl ├── aequitas_time_interval.erl ├── aequitas_work_stats_sup.erl ├── aequitas_sup.erl ├── aequitas_category_sup.erl ├── aequitas_cfg.erl ├── aequitas_boot_categories.erl ├── aequitas_proc_reg.erl ├── aequitas.erl ├── aequitas_work_stats.erl └── aequitas_category.erl ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── rebar.config ├── CHANGELOG.md ├── Makefile ├── microbenchmark.escript └── README.md /rebar.lock: -------------------------------------------------------------------------------- 1 | []. 2 | -------------------------------------------------------------------------------- /doc/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | -------------------------------------------------------------------------------- /.vimrc: -------------------------------------------------------------------------------- 1 | auto BufNewFile,BufRead *.config setlocal ft=erlang 2 | auto FileType erlang setlocal expandtab softtabstop=4 shiftwidth=4 3 | -------------------------------------------------------------------------------- /test_release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # To be invoked from Makefile 3 | 4 | set -eux 5 | 6 | REBAR3=$1 7 | "$REBAR3" as test release -n test_release 8 | _build/test/rel/test_release/bin/test_release console <<<"q()." 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .rebar3 2 | _* 3 | .eunit 4 | *.o 5 | *.beam 6 | *.plt 7 | *.swp 8 | *.swo 9 | .erlang.cookie 10 | ebin 11 | log 12 | erl_crash.dump 13 | .rebar 14 | logs 15 | _build 16 | .idea 17 | *.iml 18 | rebar3.crashdump 19 | -------------------------------------------------------------------------------- /test/etc/sys.config: -------------------------------------------------------------------------------- 1 | [ 2 | {aequitas, 3 | [{categories, 4 | #{ static_configuration_categoryA => [{max_window_size, 10}], 5 | static_configuration_categoryB => [{max_window_size, 42}], 6 | {static_configuration, non_atom_category} => [{max_window_size, 999}] 7 | }} 8 | ]} 9 | ]. 10 | -------------------------------------------------------------------------------- /rebar.config.script: -------------------------------------------------------------------------------- 1 | % vim: set ft=erlang: 2 | RunningOnCI = os:getenv("RUNNING_ON_CI"), 3 | try RunningOnCI =:= false andalso hipe:module_info() of 4 | false -> 5 | CONFIG; 6 | [_|_] -> 7 | {_, ErlOpts} = lists:keyfind(erl_opts, 1, CONFIG), 8 | Tuple = {d,'HIPE_SUPPORTED'}, 9 | lists:keystore(erl_opts, 1, CONFIG, {erl_opts, [Tuple|ErlOpts]}) 10 | catch 11 | error:undef -> 12 | CONFIG 13 | end. 14 | -------------------------------------------------------------------------------- /src/aequitas.app.src: -------------------------------------------------------------------------------- 1 | {application, aequitas, 2 | [{description, "Fairness regulator and rate limiter"}, 3 | {vsn, "git"}, 4 | {registered, []}, 5 | {mod, {aequitas_app, []}}, 6 | {applications, 7 | [kernel, 8 | stdlib 9 | ]}, 10 | {env,[]}, 11 | {modules, []}, 12 | 13 | {licenses, ["MIT"]}, 14 | {links, [{"GitHub", "https://github.com/g-andrade/aequitas"}, 15 | {"GitLab", "https://gitlab.com/g-andrade/aequitas"}]} 16 | ]}. 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: build 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | jobs: 11 | ci: 12 | name: > 13 | Run checks and tests over ${{matrix.otp_vsn}} and ${{matrix.os}} 14 | runs-on: ${{matrix.os}} 15 | container: 16 | image: erlang:${{matrix.otp_vsn}} 17 | strategy: 18 | matrix: 19 | otp_vsn: ['22.0', '22.1', '22.2', '22.3', 20 | '23.0', '23.1', '23.2', '23.3', 21 | '24.0', '24.1', '24.2', '24.3'] 22 | os: [ubuntu-latest] 23 | steps: 24 | - uses: actions/checkout@v2 25 | - run: RUNNING_ON_CI=yes make check ci_test 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2022 Guilherme Andrade 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | {cover_enabled, true}. 2 | 3 | {erl_opts, 4 | [%{i, "src"}, 5 | %bin_opt_info, 6 | debug_info, 7 | warn_export_all, 8 | warn_export_vars, 9 | warn_missing_spec, 10 | warn_obsolete_guards, 11 | warn_shadow_vars, 12 | warn_unused_import, 13 | warnings_as_errors 14 | ]}. 15 | 16 | {minimum_otp_vsn, "22"}. 17 | 18 | {dialyzer, 19 | [{plt_include_all_deps, true}, 20 | {warnings, 21 | [unmatched_returns, 22 | error_handling, 23 | underspecs 24 | ]} 25 | ]}. 26 | 27 | {xref_checks, 28 | [undefined_function_calls, 29 | undefined_functions, 30 | locals_not_used, 31 | exports_not_used, 32 | deprecated_function_calls, 33 | deprecated_functions 34 | ]}. 35 | 36 | {project_plugins, 37 | [{rebar3_hex, "6.10.3"} 38 | ]}. 39 | 40 | {profiles, 41 | [{development, 42 | [{erl_opts, 43 | [nowarn_missing_spec, 44 | nowarnings_as_errors]} 45 | ]}, 46 | 47 | {test, 48 | [{erl_opts, 49 | [debug_info, 50 | nowarn_export_all, 51 | nowarn_missing_spec, 52 | nowarnings_as_errors 53 | ]}, 54 | {ct_opts, 55 | [{sys_config, ["test/etc/sys.config"]} 56 | ]}, 57 | {relx, 58 | [{release, {test_release, "0.0.0"}, [aequitas]}, 59 | {dev_mode, true}, 60 | {include_erts, false}, 61 | {extended_start_script, true}, 62 | {sys_config, "test/etc/sys.config"} 63 | ]} 64 | ]} 65 | ]}. 66 | 67 | {edoc_opts, 68 | [{stylesheet_file, "doc/custom_stylesheet.css"} 69 | ]}. 70 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.3.0] - 2021-05-13 8 | ### Added 9 | - OTP 24 to CI targets 10 | ### Removed 11 | - compatibility with OTP 19 12 | - compatibility with OTP 20 13 | - compatibility with OTP 21 14 | 15 | ## [1.2.1] - 2020-05-26 16 | ### Fixed 17 | - outdated README 18 | 19 | ## [1.2.0] - 2020-05-26 20 | ### Removed 21 | - compatibility with OTP 18 22 | 23 | ## [1.1.3] - 2019-11-11 24 | ### Changed 25 | - generated documentation as to (tentatively) make it prettier 26 | 27 | ## [1.1.2] - 2019-09-21 28 | ### Fixed 29 | - broken execution of test cases on OTP 22.1 when HiPE is available 30 | 31 | ## [1.1.1] - 2019-01-19 32 | ### Fixed 33 | - unwarranted import of `rebar3_hex` plugin in library consumers 34 | 35 | ## [1.1.0] - 2018-11-25 36 | ### Added 37 | - test coverage of release integration 38 | - test coverage of static category configuration 39 | - test coverage of static category configuration update 40 | - test coverage of static category configuration dynamic override 41 | - test coverage of dynamic category (re)configuration 42 | ### Changed 43 | - format of static category configuration (no API breakage as the old format was incompatible with releases in any case) 44 | ### Fixed 45 | - compatibility of static category configuration with releases [thanks to leoliu for reporting the issue] 46 | 47 | ## [1.0.1] - 2018-06-12 48 | ### Fixed 49 | - building when HiPE is not supported [thanks to Rui Coelho for reporting this] 50 | 51 | ## [1.0.0] - 2018-05-06 52 | ### Added 53 | - outlier detection using IQR 54 | - collective rate limiting 55 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | REBAR3_URL=https://s3.amazonaws.com/rebar3/rebar3 2 | 3 | ifeq ($(wildcard rebar3),rebar3) 4 | REBAR3 = $(CURDIR)/rebar3 5 | endif 6 | 7 | ifdef RUNNING_ON_CI 8 | REBAR3 = ./rebar3 9 | else 10 | REBAR3 ?= $(shell test -e `which rebar3` 2>/dev/null && which rebar3 || echo "./rebar3") 11 | endif 12 | 13 | ifeq ($(REBAR3),) 14 | REBAR3 = $(CURDIR)/rebar3 15 | endif 16 | 17 | .PHONY: all build clean check dialyzer xref 18 | .PHONY: test test_ct test_release ci_test cover 19 | .PHONY: console microbenchmark doc publish 20 | 21 | .NOTPARALLEL: check test 22 | 23 | all: build 24 | 25 | build: $(REBAR3) 26 | @$(REBAR3) compile 27 | 28 | $(REBAR3): 29 | wget $(REBAR3_URL) || curl -Lo rebar3 $(REBAR3_URL) 30 | @chmod a+x rebar3 31 | 32 | clean: $(REBAR3) 33 | @$(REBAR3) clean 34 | 35 | check: dialyzer xref 36 | 37 | dialyzer: $(REBAR3) 38 | @$(REBAR3) dialyzer 39 | 40 | xref: $(REBAR3) 41 | @$(REBAR3) xref 42 | 43 | test: test_ct test_release 44 | 45 | test_ct: $(REBAR3) 46 | @$(REBAR3) as test ct 47 | 48 | test_release: $(REBAR3) 49 | ./test_release.sh $(REBAR3) 50 | 51 | ci_test: test check 52 | 53 | cover: test 54 | @$(REBAR3) as test cover 55 | 56 | console: $(REBAR3) 57 | @$(REBAR3) as development shell --apps aequitas 58 | 59 | microbenchmark: $(REBAR3) 60 | @$(REBAR3) as development shell --script microbenchmark.escript 61 | 62 | doc: $(REBAR3) 63 | @$(REBAR3) edoc 64 | 65 | README.md: doc 66 | # non-portable dirty hack follows (pandoc 2.1.1 used) 67 | # gfm: "github-flavoured markdown" 68 | @pandoc --from html --to gfm doc/overview-summary.html -o README.md 69 | @tail -n +11 <"README.md" >"README.md_" 70 | @head -n -12 <"README.md_" >"README.md" 71 | @tail -n 2 <"README.md_" >>"README.md" 72 | @rm "README.md_" 73 | 74 | publish: $(REBAR3) 75 | @$(REBAR3) hex publish 76 | -------------------------------------------------------------------------------- /doc/custom_stylesheet.css: -------------------------------------------------------------------------------- 1 | /* copied and modified from standard EDoc style sheet */ 2 | body { 3 | font-family: Verdana, Arial, Helvetica, sans-serif; 4 | margin-left: .25in; 5 | margin-right: .2in; 6 | margin-top: 0.2in; 7 | margin-bottom: 0.2in; 8 | color: #010d2c; 9 | background-color: #f9f9f9; 10 | max-width: 1024px; 11 | } 12 | h1,h2 { 13 | } 14 | div.navbar, h2.indextitle, h3.function, h3.typedecl { 15 | background-color: #e8e8e8; 16 | } 17 | div.navbar, h2.indextitle { 18 | background-image: linear-gradient(to right, #e8e8e8, #f9f9f9); 19 | } 20 | div.navbar { 21 | padding: 0.2em; 22 | border-radius: 3px; 23 | } 24 | h2.indextitle { 25 | padding: 0.4em; 26 | border-radius: 3px; 27 | } 28 | h3.function, h3.typedecl { 29 | display: inline; 30 | } 31 | div.spec { 32 | margin-left: 2em; 33 | background-color: #eeeeee; 34 | border-radius: 3px; 35 | } 36 | a.module { 37 | text-decoration:none 38 | } 39 | a.module:hover { 40 | background-color: #eeeeee; 41 | } 42 | ul.definitions { 43 | list-style-type: none; 44 | } 45 | ul.index { 46 | list-style-type: none; 47 | background-color: #eeeeee; 48 | } 49 | 50 | /* 51 | * Minor style tweaks 52 | */ 53 | ul { 54 | list-style-type: disc; 55 | } 56 | table { 57 | border-collapse: collapse; 58 | } 59 | td { 60 | padding: 3 61 | } 62 | 63 | /* 64 | * Extra style tweaks 65 | */ 66 | code, pre { 67 | background-color: #ececec; 68 | font: Consolas,"Andale Mono WT","Andale Mono","Lucida Console","Lucida Sans Typewriter","DejaVu Sans Mono","Bitstream Vera Sans Mono","Liberation Mono","Nimbus Mono L",Monaco,"Courier New",Courier,monospace; 69 | } 70 | code { 71 | padding-left: 2px; 72 | padding-right: 2px; 73 | } 74 | pre { 75 | color: #00000; 76 | padding: 5px; 77 | border: 0.5px dotted grey; 78 | border-radius: 2px; 79 | } 80 | -------------------------------------------------------------------------------- /src/aequitas_app.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2018-2022 Guilherme Andrade 2 | %% 3 | %% Permission is hereby granted, free of charge, to any person obtaining a 4 | %% copy of this software and associated documentation files (the "Software"), 5 | %% to deal in the Software without restriction, including without limitation 6 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | %% and/or sell copies of the Software, and to permit persons to whom the 8 | %% Software is furnished to do so, subject to the following conditions: 9 | %% 10 | %% The above copyright notice and this permission notice shall be included in 11 | %% all copies or substantial portions of the Software. 12 | %% 13 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | %% DEALINGS IN THE SOFTWARE. 20 | 21 | %% @private 22 | -module(aequitas_app). 23 | -behaviour(application). 24 | 25 | %%------------------------------------------------------------------- 26 | %% application Function Exports 27 | %%------------------------------------------------------------------- 28 | 29 | -export( 30 | [start/2, 31 | stop/1, 32 | config_change/3 33 | ]). 34 | 35 | -ignore_xref( 36 | [config_change/3 37 | ]). 38 | 39 | %%------------------------------------------------------------------- 40 | %% application Function Definitions 41 | %%------------------------------------------------------------------- 42 | 43 | -spec start(application:start_type(), term()) -> {ok, pid()}. 44 | start(_StartType, _StartArgs) -> 45 | aequitas_sup:start_link(). 46 | 47 | -spec stop(term()) -> ok. 48 | stop(_State) -> 49 | ok. 50 | 51 | -spec config_change([{term(), term()}], [{term(), term()}], [term()]) -> ok. 52 | config_change(Changed, New, Removed) when Changed =/= []; New =/= []; Removed =/= [] -> 53 | aequitas_category_sup:async_reload_settings_in_children(); 54 | config_change(_Changed, _New, _Removed) -> 55 | ok. 56 | -------------------------------------------------------------------------------- /src/aequitas_time_interval.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2018-2022 Guilherme Andrade 2 | %% 3 | %% Permission is hereby granted, free of charge, to any person obtaining a 4 | %% copy of this software and associated documentation files (the "Software"), 5 | %% to deal in the Software without restriction, including without limitation 6 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | %% and/or sell copies of the Software, and to permit persons to whom the 8 | %% Software is furnished to do so, subject to the following conditions: 9 | %% 10 | %% The above copyright notice and this permission notice shall be included in 11 | %% all copies or substantial portions of the Software. 12 | %% 13 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO WORK SHALL THE 16 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | %% DEALINGS IN THE SOFTWARE. 20 | 21 | -module(aequitas_time_interval). 22 | 23 | %%------------------------------------------------------------------- 24 | %% API Function Exports 25 | %%------------------------------------------------------------------- 26 | 27 | -export([to_milliseconds/1]). 28 | 29 | %%------------------------------------------------------------------- 30 | %% Record and Type 31 | %%------------------------------------------------------------------- 32 | 33 | -type t() :: {unit(), number()}. 34 | -export_type([t/0]). 35 | 36 | -type unit() :: 37 | milliseconds | 38 | seconds | 39 | minutes | 40 | hours | 41 | days | 42 | weeks. 43 | -export_type([unit/0]). 44 | 45 | %%------------------------------------------------------------------- 46 | %% API Function Definitions 47 | %%------------------------------------------------------------------- 48 | 49 | -spec to_milliseconds(t()) -> {ok, non_neg_integer()} | error. 50 | %% @private 51 | to_milliseconds({milliseconds, HowMany}) -> 52 | to_milliseconds(HowMany, 1); 53 | to_milliseconds({seconds, HowMany}) -> 54 | to_milliseconds(HowMany, 1000); 55 | to_milliseconds({minutes, HowMany}) -> 56 | to_milliseconds(HowMany, 1000 * 60); 57 | to_milliseconds({hours, HowMany}) -> 58 | to_milliseconds(HowMany, 1000 * 60 * 60); 59 | to_milliseconds({days, HowMany}) -> 60 | to_milliseconds(HowMany, 1000 * 60 * 60 * 24); 61 | to_milliseconds({weeks, HowMany}) -> 62 | to_milliseconds(HowMany, 1000 * 60 * 60 * 24 * 7); 63 | to_milliseconds(_Value) -> 64 | error. 65 | 66 | %%------------------------------------------------------------------- 67 | %% Internal Function Definitions 68 | %%------------------------------------------------------------------- 69 | 70 | to_milliseconds(HowMany, Ratio) when is_number(HowMany), HowMany >= 0 -> 71 | {ok, trunc(HowMany * Ratio)}; 72 | to_milliseconds(_HowMany, _Ratio) -> 73 | error. 74 | -------------------------------------------------------------------------------- /src/aequitas_work_stats_sup.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2018-2022 Guilherme Andrade 2 | %% 3 | %% Permission is hereby granted, free of charge, to any person obtaining a 4 | %% copy of this software and associated documentation files (the "Software"), 5 | %% to deal in the Software without restriction, including without limitation 6 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | %% and/or sell copies of the Software, and to permit persons to whom the 8 | %% Software is furnished to do so, subject to the following conditions: 9 | %% 10 | %% The above copyright notice and this permission notice shall be included in 11 | %% all copies or substantial portions of the Software. 12 | %% 13 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | %% DEALINGS IN THE SOFTWARE. 20 | 21 | %% @private 22 | -module(aequitas_work_stats_sup). 23 | -behaviour(supervisor). 24 | 25 | %% ------------------------------------------------------------------ 26 | %% API Function Exports 27 | %% ------------------------------------------------------------------ 28 | 29 | -export( 30 | [start_link/0, 31 | start_child/1 32 | ]). 33 | 34 | -ignore_xref( 35 | [start_link/0 36 | ]). 37 | 38 | %% ------------------------------------------------------------------ 39 | %% supervisor Function Exports 40 | %% ------------------------------------------------------------------ 41 | 42 | -export([init/1]). 43 | 44 | %% ------------------------------------------------------------------ 45 | %% Macro Definitions 46 | %% ------------------------------------------------------------------ 47 | 48 | -define(CB_MODULE, ?MODULE). 49 | -define(SERVER, ?MODULE). 50 | 51 | %% ------------------------------------------------------------------ 52 | %% API Function Definitions 53 | %% ------------------------------------------------------------------ 54 | 55 | -spec start_link() -> {ok, pid()}. 56 | start_link() -> 57 | supervisor:start_link({local, ?SERVER}, ?CB_MODULE, []). 58 | 59 | -spec start_child([term()]) -> {ok, pid()} | {error, term()}. 60 | start_child(Args) -> 61 | supervisor:start_child(?SERVER, Args). 62 | 63 | %% ------------------------------------------------------------------ 64 | %% supervisor Function Definitions 65 | %% ------------------------------------------------------------------ 66 | 67 | -spec init([]) -> {ok, {supervisor:sup_flags(), 68 | [supervisor:child_spec(), ...]}}. 69 | init([]) -> 70 | SupFlags = 71 | #{ strategy => simple_one_for_one, 72 | intensity => 10, 73 | period => 1 74 | }, 75 | ChildSpecs = 76 | [#{ id => work_stats, 77 | start => {aequitas_work_stats, start_link, []}, 78 | restart => temporary 79 | } 80 | ], 81 | {ok, {SupFlags, ChildSpecs}}. 82 | -------------------------------------------------------------------------------- /src/aequitas_sup.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2018-2022 Guilherme Andrade 2 | %% 3 | %% Permission is hereby granted, free of charge, to any person obtaining a 4 | %% copy of this software and associated documentation files (the "Software"), 5 | %% to deal in the Software without restriction, including without limitation 6 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | %% and/or sell copies of the Software, and to permit persons to whom the 8 | %% Software is furnished to do so, subject to the following conditions: 9 | %% 10 | %% The above copyright notice and this permission notice shall be included in 11 | %% all copies or substantial portions of the Software. 12 | %% 13 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | %% DEALINGS IN THE SOFTWARE. 20 | 21 | %% @private 22 | -module(aequitas_sup). 23 | -behaviour(supervisor). 24 | 25 | %% ------------------------------------------------------------------ 26 | %% API Function Exports 27 | %% ------------------------------------------------------------------ 28 | 29 | -export( 30 | [start_link/0 31 | ]). 32 | 33 | -ignore_xref( 34 | [start_link/0 35 | ]). 36 | 37 | %% ------------------------------------------------------------------ 38 | %% supervisor Function Exports 39 | %% ------------------------------------------------------------------ 40 | 41 | -export( 42 | [init/1 43 | ]). 44 | 45 | %% ------------------------------------------------------------------ 46 | %% Macro Definitions 47 | %% ------------------------------------------------------------------ 48 | 49 | -define(SERVER, ?MODULE). 50 | 51 | %% ------------------------------------------------------------------ 52 | %% API Function Definitions 53 | %% ------------------------------------------------------------------ 54 | 55 | -spec start_link() -> {ok, pid()}. 56 | start_link() -> 57 | supervisor:start_link({local, ?SERVER}, ?MODULE, []). 58 | 59 | %% ------------------------------------------------------------------ 60 | %% supervisor Function Definitions 61 | %% ------------------------------------------------------------------ 62 | 63 | -spec init([]) -> {ok, {supervisor:sup_flags(), 64 | [supervisor:child_spec(), ...]}}. 65 | init([]) -> 66 | SupFlags = 67 | #{ strategy => rest_for_one, 68 | intensity => 10, 69 | period => 1 70 | }, 71 | ChildSpecs = 72 | [#{ id => proc_reg, 73 | start => {aequitas_proc_reg, start_link, []} 74 | }, 75 | #{ id => cfg, 76 | start => {aequitas_cfg, start_link, []} 77 | }, 78 | #{ id => work_stats_sup, 79 | start => {aequitas_work_stats_sup, start_link, []}, 80 | type => supervisor 81 | }, 82 | #{ id => category_sup, 83 | start => {aequitas_category_sup, start_link, []}, 84 | type => supervisor 85 | }, 86 | #{ id => boot_categories, 87 | start => {aequitas_boot_categories, start_link, []} 88 | }], 89 | {ok, {SupFlags, ChildSpecs}}. 90 | -------------------------------------------------------------------------------- /src/aequitas_category_sup.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2018-2022 Guilherme Andrade 2 | %% 3 | %% Permission is hereby granted, free of charge, to any person obtaining a 4 | %% copy of this software and associated documentation files (the "Software"), 5 | %% to deal in the Software without restriction, including without limitation 6 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | %% and/or sell copies of the Software, and to permit persons to whom the 8 | %% Software is furnished to do so, subject to the following conditions: 9 | %% 10 | %% The above copyright notice and this permission notice shall be included in 11 | %% all copies or substantial portions of the Software. 12 | %% 13 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | %% DEALINGS IN THE SOFTWARE. 20 | 21 | %% @private 22 | -module(aequitas_category_sup). 23 | -behaviour(supervisor). 24 | 25 | %% ------------------------------------------------------------------ 26 | %% API Function Exports 27 | %% ------------------------------------------------------------------ 28 | 29 | -export( 30 | [start_link/0, 31 | start_child/1, 32 | async_reload_settings_in_children/0 33 | ]). 34 | 35 | -ignore_xref( 36 | [start_link/0 37 | ]). 38 | 39 | %% ------------------------------------------------------------------ 40 | %% supervisor Function Exports 41 | %% ------------------------------------------------------------------ 42 | 43 | -export([init/1]). 44 | 45 | %% ------------------------------------------------------------------ 46 | %% Macro Definitions 47 | %% ------------------------------------------------------------------ 48 | 49 | -define(CB_MODULE, ?MODULE). 50 | -define(SERVER, ?MODULE). 51 | 52 | %% ------------------------------------------------------------------ 53 | %% API Function Definitions 54 | %% ------------------------------------------------------------------ 55 | 56 | -spec start_link() -> {ok, pid()}. 57 | start_link() -> 58 | supervisor:start_link({local, ?SERVER}, ?CB_MODULE, []). 59 | 60 | -spec start_child([term()]) -> {ok, pid()} | {error, term()}. 61 | start_child(Args) -> 62 | supervisor:start_child(?SERVER, Args). 63 | 64 | -spec async_reload_settings_in_children() -> ok. 65 | async_reload_settings_in_children() -> 66 | lists:foreach( 67 | fun ({_Id, undefined, _Type, _Modules}) -> 68 | ok; 69 | ({_Id, Pid, _Type, _Modules}) -> 70 | aequitas_category:async_reload_settings(Pid) 71 | end, 72 | supervisor:which_children(?SERVER)). 73 | 74 | %% ------------------------------------------------------------------ 75 | %% supervisor Function Definitions 76 | %% ------------------------------------------------------------------ 77 | 78 | -spec init([]) -> {ok, {supervisor:sup_flags(), 79 | [supervisor:child_spec(), ...]}}. 80 | init([]) -> 81 | SupFlags = 82 | #{ strategy => simple_one_for_one, 83 | intensity => 10, 84 | period => 1 85 | }, 86 | ChildSpecs = 87 | [#{ id => category, 88 | start => {aequitas_category, start_link, []}, 89 | restart => temporary 90 | } 91 | ], 92 | {ok, {SupFlags, ChildSpecs}}. 93 | -------------------------------------------------------------------------------- /microbenchmark.escript: -------------------------------------------------------------------------------- 1 | -module(microbenchmark). 2 | -mode(compile). 3 | 4 | -export([main/1]). 5 | 6 | -define(NR_OF_WORKERS, 100). 7 | 8 | main([]) -> 9 | Category = microbenchmarking, 10 | NrOfWorkers = ?NR_OF_WORKERS, 11 | NrOfCalls = 2000000, 12 | {ok, _} = application:ensure_all_started(aequitas), 13 | {ok, _} = application:ensure_all_started(sasl), 14 | ok = aequitas:start(Category), 15 | do_it(Category, NrOfWorkers, NrOfCalls). 16 | 17 | do_it(Category, NrOfWorkers, NrOfCalls) -> 18 | NrOfCallsPerWorker = NrOfCalls div NrOfWorkers, 19 | Parent = self(), 20 | Pids = [spawn(fun () -> run_worker(Category, Nr, Parent, NrOfCallsPerWorker) end) 21 | || Nr <- lists:seq(1, NrOfWorkers)], 22 | WithMonitors = [{Pid, monitor(process, Pid)} || Pid <- Pids], 23 | io:format("running benchmarks... (~p calls using ~p workers)~n", 24 | [NrOfCalls, NrOfWorkers]), 25 | wait_for_workers(WithMonitors, [], []). 26 | 27 | wait_for_workers([], ResultAcc, SecondsToGenerateAcc) -> 28 | UniqueAequitasResults = lists:usort( lists:flatten([maps:keys(M) || M <- ResultAcc]) ), 29 | lists:foreach( 30 | fun (AequitasResult) -> 31 | Counts = [maps:get(AequitasResult, M, 0) || M <- ResultAcc], 32 | TotalCount = trunc(lists:sum(Counts)), 33 | io:format("achieved an average of ~p '~p' results per second~n", 34 | [TotalCount, AequitasResult]) 35 | end, 36 | UniqueAequitasResults), 37 | StatsOfSecondsToGenerateStats = 38 | #{ min => lists:min(SecondsToGenerateAcc), 39 | max => lists:max(SecondsToGenerateAcc), 40 | avg => lists:sum(SecondsToGenerateAcc) / length(SecondsToGenerateAcc) 41 | }, 42 | io:format("stats of stats' seconds_to_generate: ~p~n", [StatsOfSecondsToGenerateStats]), 43 | ok = aequitas:stop(microbenchmarking), 44 | erlang:halt(); 45 | wait_for_workers(WithMonitors, ResultAcc, SecondsToGenerateAcc) -> 46 | receive 47 | {worker_result, Pid, Result, WorkerSecondsToGenerateAcc} -> 48 | {value, {Pid, Monitor}, UpdatedWithMonitors} = lists:keytake(Pid, 1, WithMonitors), 49 | demonitor(Monitor, [flush]), 50 | UpdatedResultsAcc = [Result | ResultAcc], 51 | UpdatedSecondsToGenerateAcc = WorkerSecondsToGenerateAcc ++ SecondsToGenerateAcc, 52 | wait_for_workers(UpdatedWithMonitors, UpdatedResultsAcc, UpdatedSecondsToGenerateAcc); 53 | {'DOWN', _Ref, process, _Pid, Reason} -> 54 | error(Reason) 55 | end. 56 | 57 | run_worker(Category, Nr, Parent, NrOfCalls) -> 58 | run_worker_loop(Category, Nr, Parent, NrOfCalls, 59 | erlang:monotonic_time(), 0, #{}, []). 60 | 61 | run_worker_loop(_Category, _Nr, Parent, NrOfCalls, StartTs, 62 | Count, CountPerResult, SecondsToGenerateAcc) when Count =:= NrOfCalls -> 63 | EndTs = erlang:monotonic_time(), 64 | TimeElapsed = EndTs - StartTs, 65 | NativeTimeRatio = erlang:convert_time_unit(1, seconds, native), 66 | SecondsElapsed = TimeElapsed / NativeTimeRatio, 67 | AdjustedCountPerResult = 68 | maps:map( 69 | fun (_Result, Count) -> 70 | Count / SecondsElapsed 71 | end, 72 | CountPerResult), 73 | Parent ! {worker_result, self(), AdjustedCountPerResult, SecondsToGenerateAcc}; 74 | run_worker_loop(Category, Nr, Parent, NrOfCalls, StartTs, Count, CountPerResult, SecondsToGenerateAcc) -> 75 | ActorId = abs(erlang:monotonic_time()) rem ?NR_OF_WORKERS, 76 | {Result, Stats} = aequitas:ask(Category, ActorId, [return_stats]), 77 | %_ = (Count rem 10000 =:= 10) andalso io:format("Stats (~p): ~p~n", [Nr, Stats]), 78 | true = (Result =/= error), 79 | UpdatedCountPerResult = maps_increment(Result, +1, CountPerResult), 80 | SecondsToGenerateStats = maps:get(seconds_to_generate, Stats), 81 | UpdatedSecondsToGenerateAcc = [SecondsToGenerateStats | SecondsToGenerateAcc], 82 | run_worker_loop(Category, Nr, Parent, NrOfCalls, StartTs, Count + 1, 83 | UpdatedCountPerResult, UpdatedSecondsToGenerateAcc). 84 | 85 | maps_increment(Key, Incr, Map) -> 86 | maps:update_with( 87 | Key, 88 | fun (Value) -> Value + Incr end, 89 | Incr, Map). 90 | -------------------------------------------------------------------------------- /src/aequitas_cfg.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2018-2022 Guilherme Andrade 2 | %% 3 | %% Permission is hereby granted, free of charge, to any person obtaining a 4 | %% copy of this software and associated documentation files (the "Software"), 5 | %% to deal in the Software without restriction, including without limitation 6 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | %% and/or sell copies of the Software, and to permit persons to whom the 8 | %% Software is furnished to do so, subject to the following conditions: 9 | %% 10 | %% The above copyright notice and this permission notice shall be included in 11 | %% all copies or substantial portions of the Software. 12 | %% 13 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | %% DEALINGS IN THE SOFTWARE. 20 | 21 | %% @private 22 | -module(aequitas_cfg). 23 | -behaviour(gen_server). 24 | 25 | %% ------------------------------------------------------------------ 26 | %% API Function Exports 27 | %% ------------------------------------------------------------------ 28 | 29 | -export( 30 | [start_link/0, 31 | category_get/2, 32 | category_set/2 33 | ]). 34 | 35 | -ignore_xref( 36 | [start_link/0 37 | ]). 38 | 39 | %% ------------------------------------------------------------------ 40 | %% gen_server Function Exports 41 | %% ------------------------------------------------------------------ 42 | 43 | -export( 44 | [init/1, 45 | handle_call/3, 46 | handle_cast/2, 47 | handle_info/2, 48 | terminate/2, 49 | code_change/3 50 | ]). 51 | 52 | %% ------------------------------------------------------------------ 53 | %% Macro Definitions 54 | %% ------------------------------------------------------------------ 55 | 56 | -define(CB_MODULE, ?MODULE). 57 | -define(SERVER, ?MODULE). 58 | -define(TABLE, ?MODULE). 59 | 60 | %% ------------------------------------------------------------------ 61 | %% Record and Type Definitions 62 | %% ------------------------------------------------------------------ 63 | 64 | -type state() :: no_state. 65 | 66 | %% ------------------------------------------------------------------ 67 | %% API Function Definitions 68 | %% ------------------------------------------------------------------ 69 | 70 | -spec start_link() -> {ok, pid()}. 71 | start_link() -> 72 | gen_server:start_link({local, ?SERVER}, ?CB_MODULE, [], []). 73 | 74 | -spec category_get(term(), [aequitas_category:setting_opt()]) -> [aequitas_category:setting_opt()]. 75 | category_get(Category, DefaultSettingOpts) -> 76 | DynamicKey = dynamic_category_key(Category), 77 | case ets:lookup(?TABLE, DynamicKey) of 78 | [{DynamicKey, DynamicSettingOpts}] -> 79 | DynamicSettingOpts; 80 | [] -> 81 | case application:get_env(aequitas, categories, #{}) of 82 | #{ Category := StaticSettingOpts } -> 83 | StaticSettingOpts; 84 | #{} -> 85 | DefaultSettingOpts 86 | end 87 | end. 88 | 89 | -spec category_set(term(), [aequitas_category:setting_opt()]) -> true. 90 | category_set(Category, SettingOpts) -> 91 | Key = dynamic_category_key(Category), 92 | ets:insert(?TABLE, {Key, SettingOpts}). 93 | 94 | %% ------------------------------------------------------------------ 95 | %% gen_server Function Definitions 96 | %% ------------------------------------------------------------------ 97 | 98 | -spec init([]) -> {ok, state()}. 99 | init([]) -> 100 | EtsOpts = [named_table, public, {read_concurrency,true}], 101 | _ = ets:new(?TABLE, EtsOpts), 102 | {ok, no_state}. 103 | 104 | -spec handle_call(term(), {pid(), reference()}, state()) 105 | -> {stop, unexpected_call, state()}. 106 | handle_call(_Call, _From, State) -> 107 | {stop, unexpected_call, State}. 108 | 109 | -spec handle_cast(term(), state()) -> {stop, unexpected_cast, state()}. 110 | handle_cast(_Cast, State) -> 111 | {stop, unexpected_cast, State}. 112 | 113 | -spec handle_info(term(), state()) -> {stop, unexpected_info, state()}. 114 | handle_info(_Info, State) -> 115 | {stop, unexpected_info, State}. 116 | 117 | -spec terminate(term(), state()) -> ok. 118 | terminate(_Reason, _State) -> 119 | ok. 120 | 121 | -spec code_change(term(), state(), term()) -> {ok, state()}. 122 | code_change(_OldVsn, State, _Extra) -> 123 | {ok, State}. 124 | 125 | %% ------------------------------------------------------------------ 126 | %% Internal Function Definitions 127 | %% ------------------------------------------------------------------ 128 | 129 | -spec dynamic_category_key(term()) -> {category, term()}. 130 | dynamic_category_key(Category) -> 131 | {category, Category}. 132 | -------------------------------------------------------------------------------- /src/aequitas_boot_categories.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2018-2022 Guilherme Andrade 2 | %% 3 | %% Permission is hereby granted, free of charge, to any person obtaining a 4 | %% copy of this software and associated documentation files (the "Software"), 5 | %% to deal in the Software without restriction, including without limitation 6 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | %% and/or sell copies of the Software, and to permit persons to whom the 8 | %% Software is furnished to do so, subject to the following conditions: 9 | %% 10 | %% The above copyright notice and this permission notice shall be included in 11 | %% all copies or substantial portions of the Software. 12 | %% 13 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | %% DEALINGS IN THE SOFTWARE. 20 | 21 | %% @private 22 | -module(aequitas_boot_categories). 23 | -behaviour(gen_server). 24 | 25 | %% ------------------------------------------------------------------ 26 | %% API Function Exports 27 | %% ------------------------------------------------------------------ 28 | 29 | -export( 30 | [start_link/0 31 | ]). 32 | 33 | -ignore_xref( 34 | [start_link/0 35 | ]). 36 | 37 | %% ------------------------------------------------------------------ 38 | %% gen_server Function Exports 39 | %% ------------------------------------------------------------------ 40 | 41 | -export( 42 | [init/1, 43 | handle_call/3, 44 | handle_cast/2, 45 | handle_info/2, 46 | terminate/2, 47 | code_change/3 48 | ]). 49 | 50 | %% ------------------------------------------------------------------ 51 | %% Macro Definitions 52 | %% ------------------------------------------------------------------ 53 | 54 | -define(CB_MODULE, ?MODULE). 55 | -define(SERVER, ?MODULE). 56 | 57 | %% ------------------------------------------------------------------ 58 | %% Record and Type Definitions 59 | %% ------------------------------------------------------------------ 60 | 61 | -type state() :: no_state. 62 | 63 | %% ------------------------------------------------------------------ 64 | %% API Function Definitions 65 | %% ------------------------------------------------------------------ 66 | 67 | -spec start_link() -> {ok, pid()}. 68 | start_link() -> 69 | gen_server:start_link({local, ?SERVER}, ?CB_MODULE, [], []). 70 | 71 | %% ------------------------------------------------------------------ 72 | %% gen_server Function Definitions 73 | %% ------------------------------------------------------------------ 74 | 75 | -spec init([]) -> {ok, state()} | {stop, {atom(), term()}}. 76 | init([]) -> 77 | try launch_foreknown_categories() of 78 | ok -> 79 | {ok, no_state} 80 | catch 81 | Class:Reason -> 82 | {stop, {Class, Reason}} 83 | end. 84 | 85 | -spec handle_call(term(), {pid(), reference()}, state()) 86 | -> {stop, unexpected_call, state()}. 87 | handle_call(_Call, _From, State) -> 88 | {stop, unexpected_call, State}. 89 | 90 | -spec handle_cast(term(), state()) -> {stop, unexpected_cast, state()}. 91 | handle_cast(_Cast, State) -> 92 | {stop, unexpected_cast, State}. 93 | 94 | -spec handle_info(term(), state()) -> {stop, unexpected_info, state()}. 95 | handle_info(_Info, State) -> 96 | {stop, unexpected_info, State}. 97 | 98 | -spec terminate(term(), state()) -> ok. 99 | terminate(_Reason, _State) -> 100 | ok. 101 | 102 | -spec code_change(term(), state(), term()) -> {ok, state()}. 103 | code_change(_OldVsn, State, _Extra) -> 104 | {ok, State}. 105 | 106 | %% ------------------------------------------------------------------ 107 | %% Internal Function Definitions 108 | %% ------------------------------------------------------------------ 109 | 110 | -spec launch_foreknown_categories() -> ok | no_return(). 111 | launch_foreknown_categories() -> 112 | AppConfig = application:get_all_env(aequitas), 113 | lists:foreach( 114 | fun ({categories, SettingOptsPerCategory}) -> 115 | launch_foreknown_categories(SettingOptsPerCategory); 116 | ({{category, Category}, _SettingOpts}) -> 117 | % legacy format which is not compatible with releases 118 | % (nor does it respect the documented sys.config 119 | % constraints - all keys must be atoms) 120 | error({release_incompatible_static_configuration, Category}); 121 | ({_Key, _Value}) -> 122 | ok 123 | end, 124 | AppConfig). 125 | 126 | -spec launch_foreknown_categories(#{ term() => [aequitas_category:setting_opt()] }) -> ok | no_return(). 127 | launch_foreknown_categories(SettingOptsPerCategory) when is_map(SettingOptsPerCategory) -> 128 | lists:foreach( 129 | fun ({Category, SettingOpts}) -> 130 | launch_foreknown_category(Category, SettingOpts) 131 | end, 132 | maps:to_list(SettingOptsPerCategory)). 133 | 134 | -spec launch_foreknown_category(term(), [aequitas_category:setting_opt()]) -> ok | no_return(). 135 | launch_foreknown_category(Category, SettingOpts) -> 136 | case aequitas_category:start(Category, false, SettingOpts) of 137 | {ok, _Pid} -> 138 | ok; 139 | {error, Reason} -> 140 | error(#{ category => Category, 141 | reason => Reason }) 142 | end. 143 | -------------------------------------------------------------------------------- /src/aequitas_proc_reg.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2018-2022 Guilherme Andrade 2 | %% 3 | %% Permission is hereby granted, free of charge, to any person obtaining a 4 | %% copy of this software and associated documentation files (the "Software"), 5 | %% to deal in the Software without restriction, including without limitation 6 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | %% and/or sell copies of the Software, and to permit persons to whom the 8 | %% Software is furnished to do so, subject to the following conditions: 9 | %% 10 | %% The above copyright notice and this permission notice shall be included in 11 | %% all copies or substantial portions of the Software. 12 | %% 13 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO WORK SHALL THE 16 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | %% DEALINGS IN THE SOFTWARE. 20 | 21 | %% @private 22 | -module(aequitas_proc_reg). 23 | -behaviour(gen_server). 24 | 25 | %% ------------------------------------------------------------------ 26 | %% API Function Exports 27 | %% ------------------------------------------------------------------ 28 | 29 | -export( 30 | [start_link/0, 31 | register/2, 32 | whereis/1 33 | ]). 34 | 35 | -ignore_xref( 36 | [start_link/0 37 | ]). 38 | 39 | %% ------------------------------------------------------------------ 40 | %% gen_server Function Exports 41 | %% ------------------------------------------------------------------ 42 | 43 | -export( 44 | [init/1, 45 | handle_call/3, 46 | handle_cast/2, 47 | handle_info/2, 48 | terminate/2, 49 | code_change/3 50 | ]). 51 | 52 | %% ------------------------------------------------------------------ 53 | %% Macro Definitions 54 | %% ------------------------------------------------------------------ 55 | 56 | -define(CB_MODULE, ?MODULE). 57 | -define(SERVER, ?MODULE). 58 | -define(TABLE, ?MODULE). 59 | 60 | %% ------------------------------------------------------------------ 61 | %% Record and Type Definitions 62 | %% ------------------------------------------------------------------ 63 | 64 | -record(state, { 65 | monitors :: #{ reference() => term() } 66 | }). 67 | -type state() :: #state{}. 68 | 69 | %% ------------------------------------------------------------------ 70 | %% API Function Definitions 71 | %% ------------------------------------------------------------------ 72 | 73 | -spec start_link() -> {ok, pid()}. 74 | start_link() -> 75 | gen_server:start_link({local, ?SERVER}, ?CB_MODULE, [], []). 76 | 77 | -spec register(term(), pid()) -> ok | {error, {already_registered, pid()}}. 78 | register(Name, Pid) -> 79 | gen_server:call(?SERVER, {register, Name, Pid}, infinity). 80 | 81 | -spec whereis(term()) -> pid() | undefined. 82 | whereis(Name) -> 83 | case ets:lookup(?TABLE, Name) of 84 | [{_, Pid}] -> Pid; 85 | _ -> undefined 86 | end. 87 | 88 | %% ------------------------------------------------------------------ 89 | %% gen_server Function Definitions 90 | %% ------------------------------------------------------------------ 91 | 92 | -spec init([]) -> {ok, state()}. 93 | init([]) -> 94 | EtsOpts = [named_table, protected, {read_concurrency,true}], 95 | _ = ets:new(?TABLE, EtsOpts), 96 | {ok, #state{ monitors = #{} }}. 97 | 98 | -spec handle_call(term(), {pid(),reference()}, state()) 99 | -> {reply, Reply, state()} | 100 | {stop, unexpected_call, state()} 101 | when Reply :: ok | {error, {already_registered,pid()}}. 102 | handle_call({register, Name, Pid}, _From, State) -> 103 | case ets:lookup(?TABLE, Name) of 104 | [{_, ExistingPid}] -> 105 | {reply, {error, {already_registered, ExistingPid}}, State}; 106 | [] -> 107 | ets:insert(?TABLE, {Name,Pid}), 108 | NewMonitor = monitor(process, Pid), 109 | Monitors = State#state.monitors, 110 | UpdatedMonitors = Monitors#{ NewMonitor => Name }, 111 | UpdatedState = State#state{ monitors = UpdatedMonitors }, 112 | {reply, ok, UpdatedState} 113 | end; 114 | handle_call(_Call, _From, State) -> 115 | {stop, unexpected_call, State}. 116 | 117 | -spec handle_cast(term(), state()) -> {stop, unexpected_cast, state()}. 118 | handle_cast(_Cast, State) -> 119 | {stop, unexpected_cast, State}. 120 | 121 | -spec handle_info(term(), state()) 122 | -> {noreply, state()} | 123 | {stop, unexpected_info, state()}. 124 | handle_info({'DOWN', Ref, process, _Pid, _Reason}, State) -> 125 | Monitors = State#state.monitors, 126 | {Name, UpdatedMonitors} = maps_take(Ref, Monitors), 127 | [_] = ets:take(?TABLE, Name), 128 | UpdatedState = State#state{ monitors = UpdatedMonitors }, 129 | {noreply, UpdatedState}; 130 | handle_info(_Info, State) -> 131 | {stop, unexpected_info, State}. 132 | 133 | -spec terminate(term(), state()) -> ok. 134 | terminate(_Reason, _State) -> 135 | ok. 136 | 137 | -spec code_change(term(), state(), term()) -> {ok, state()}. 138 | code_change(_OldVsn, #state{} = State, _Extra) -> 139 | {ok, State}. 140 | 141 | %% ------------------------------------------------------------------ 142 | %% Internal Function Definitions 143 | %% ------------------------------------------------------------------ 144 | 145 | maps_take(Key, Map) -> 146 | % OTP 18 doesn't include maps:take/2 147 | case maps:find(Key, Map) of 148 | {ok, Value} -> 149 | UpdatedMap = maps:remove(Key, Map), 150 | {Value, UpdatedMap}; 151 | error -> 152 | error 153 | end. 154 | -------------------------------------------------------------------------------- /src/aequitas.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2018-2022 Guilherme Andrade 2 | %% 3 | %% Permission is hereby granted, free of charge, to any person obtaining a 4 | %% copy of this software and associated documentation files (the "Software"), 5 | %% to deal in the Software without restriction, including without limitation 6 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | %% and/or sell copies of the Software, and to permit persons to whom the 8 | %% Software is furnished to do so, subject to the following conditions: 9 | %% 10 | %% The above copyright notice and this permission notice shall be included in 11 | %% all copies or substantial portions of the Software. 12 | %% 13 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | %% DEALINGS IN THE SOFTWARE. 20 | 21 | -module(aequitas). 22 | 23 | %%------------------------------------------------------------------- 24 | %% API Function Exports 25 | %%------------------------------------------------------------------- 26 | 27 | -export( 28 | [start/1, 29 | start/2, 30 | stop/1, 31 | ask/2, 32 | ask/3, 33 | async_ask/2, 34 | async_ask/3, 35 | reconfigure/2 36 | ]). 37 | 38 | -ignore_xref( 39 | [start/1, 40 | start/2, 41 | stop/1, 42 | ask/2, 43 | ask/3, 44 | async_ask/2, 45 | async_ask/3, 46 | reconfigure/2 47 | ]). 48 | 49 | %%------------------------------------------------------------------- 50 | %% API Function Definitions 51 | %%------------------------------------------------------------------- 52 | 53 | %% @doc Like `:async_ask/3' but with default options 54 | %% @see start/2 55 | %% @see stop/1 56 | %% @see reconfigure/2 57 | -spec start(Category) -> ok | {error, Reason} 58 | when Category :: term(), 59 | Reason :: already_started. 60 | start(Category) -> 61 | start(Category, []). 62 | 63 | %% @doc Starts handler for `Category' 64 | %% 65 | %% 69 | %% 70 | %% Returns: 71 | %% 75 | %% @see start/1 76 | %% @see stop/1 77 | %% @see reconfigure/2 78 | -spec start(Category, Opts) -> ok | {error, Reason} 79 | when Category :: term(), 80 | Opts :: [aequitas_category:setting_opt()], 81 | Reason :: (already_started | 82 | {invalid_setting_opt, term()} | 83 | {invalid_setting_opts, term()}). 84 | start(Category, Opts) -> 85 | case aequitas_category:start(Category, true, Opts) of 86 | {ok, _Pid} -> 87 | ok; 88 | {error, {already_started, _Pid}} -> 89 | {error, already_started}; 90 | {error, Reason} -> 91 | {error, Reason} 92 | end. 93 | 94 | %% @doc Stops a `Category' handler 95 | %% 96 | %% 99 | %% 100 | %% Returns: 101 | %% 105 | %% @see start/1 106 | %% @see reconfigure/2 107 | -spec stop(Category) -> ok | {error, Reason} 108 | when Category :: term(), 109 | Reason :: not_started. 110 | stop(Category) -> 111 | aequitas_category:stop(Category). 112 | 113 | %% @doc Like `:ask/3' but with default options 114 | %% @see ask/3 115 | %% @see async_ask/3 116 | %% @see async_ask/2 117 | -spec ask(Category, ActorId) -> Status | {error, Reason} 118 | when Category :: term(), 119 | ActorId :: term(), 120 | Status :: accepted | {rejected, RejectionReason}, 121 | RejectionReason :: outlier | rate_limited, 122 | Reason :: not_started. 123 | ask(Category, ActorId) -> 124 | ask(Category, ActorId, []). 125 | 126 | %% @doc Request permission to perform work, identified under `ActorId', within `Category' 127 | %% 128 | %% 133 | %% 134 | %% Returns: 135 | %% 140 | %% @see ask/2 141 | %% @see async_ask/3 142 | %% @see async_ask/2 143 | -spec ask(Category, ActorId, Opts) -> Status | {Status, Stats} | 144 | {error, Reason} 145 | when Category :: term(), 146 | ActorId :: term(), 147 | Opts :: [aequitas_category:ask_opt()], 148 | Status :: accepted | {rejected, RejectionReason}, 149 | RejectionReason :: outlier | rate_limited, 150 | Stats :: aequitas_work_stats:t(), 151 | Reason :: not_started. 152 | ask(Category, ActorId, Opts) -> 153 | aequitas_category:ask(Category, ActorId, Opts). 154 | 155 | %% @doc Like `:async_ask/3' but with default options 156 | %% @see async_ask/3 157 | %% @see ask/3 158 | %% @see ask/2 159 | -spec async_ask(Category, ActorId) -> {Tag, Monitor} 160 | when Category :: term(), 161 | ActorId :: term(), 162 | Tag :: reference(), 163 | Monitor :: reference(). 164 | async_ask(Category, ActorId) -> 165 | async_ask(Category, ActorId, []). 166 | 167 | %% @doc Like `:ask/3' but the reply is sent asynchronously 168 | %% 169 | %% Returns a `{Tag, Monitor}' pair whose members can be used 170 | %% to pattern match against the reply, which will be sent as a message 171 | %% to the calling process in one of the following formats: 172 | %% 179 | %% 180 | %% In case of a successful reply, don't forget to clean `Monitor' up, 181 | %% which can be done like this: 182 | %% `demonitor(Monitor, [flush])' 183 | %% 184 | %% @see async_ask/2 185 | %% @see ask/3 186 | %% @see ask/2 187 | -spec async_ask(Category, ActorId, Opts) -> {Tag, Monitor} 188 | when Category :: term(), 189 | ActorId :: term(), 190 | Opts :: [aequitas_category:ask_opt()], 191 | Tag :: reference(), 192 | Monitor :: reference(). 193 | async_ask(Category, ActorId, Opts) -> 194 | aequitas_category:async_ask(Category, ActorId, Opts). 195 | 196 | %% @doc Tweak settings of work `Category' 197 | %% 198 | %% 202 | %% 203 | %% Returns: 204 | %% 208 | %% 209 | %% @see start/2 210 | %% @see stop/1 211 | -spec reconfigure(Category, SettingOpts) 212 | -> ok | {error, Reason} 213 | when Category :: term(), 214 | SettingOpts :: [aequitas_category:setting_opt()], 215 | Reason :: not_started | {invalid_setting_opt | invalid_setting_opts, term()}. 216 | reconfigure(Category, SettingOpts) -> 217 | aequitas_category:update_settings(Category, SettingOpts). 218 | -------------------------------------------------------------------------------- /src/aequitas_work_stats.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2018-2022 Guilherme Andrade 2 | %% 3 | %% Permission is hereby granted, free of charge, to any person obtaining a 4 | %% copy of this software and associated documentation files (the "Software"), 5 | %% to deal in the Software without restriction, including without limitation 6 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | %% and/or sell copies of the Software, and to permit persons to whom the 8 | %% Software is furnished to do so, subject to the following conditions: 9 | %% 10 | %% The above copyright notice and this permission notice shall be included in 11 | %% all copies or substantial portions of the Software. 12 | %% 13 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO WORK SHALL THE 16 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | %% DEALINGS IN THE SOFTWARE. 20 | 21 | -module(aequitas_work_stats). 22 | 23 | -include_lib("stdlib/include/ms_transform.hrl"). 24 | 25 | % https://gist.github.com/marcelog/97708058cd17f86326c82970a7f81d40#file-simpleproc-erl 26 | 27 | %%------------------------------------------------------------------- 28 | %% API Function Exports 29 | %%------------------------------------------------------------------- 30 | 31 | -export( 32 | [start_link/2, 33 | start/2, 34 | generate_work_stats/1 35 | ]). 36 | 37 | -ignore_xref( 38 | [start_link/2 39 | ]). 40 | 41 | %%------------------------------------------------------------------- 42 | %% OTP Function Exports 43 | %%------------------------------------------------------------------- 44 | 45 | -export( 46 | [init/1, 47 | system_code_change/4, 48 | system_continue/3, 49 | system_terminate/4, 50 | write_debug/3 51 | ]). 52 | 53 | -ignore_xref( 54 | [init/1, 55 | system_code_change/4, 56 | system_continue/3, 57 | system_terminate/4, 58 | write_debug/3 59 | ]). 60 | 61 | %%------------------------------------------------------------------- 62 | %% Record and Type Definitions 63 | %%------------------------------------------------------------------- 64 | 65 | -record(state, { 66 | category_pid :: pid(), 67 | category_mon :: reference(), 68 | work_shares_table :: ets:tab() 69 | }). 70 | -type state() :: #state{}. 71 | 72 | -type t() :: 73 | #{ actor_count => non_neg_integer(), 74 | q1 => number(), 75 | q2 => number(), 76 | q3 => number(), 77 | iqr => number(), 78 | seconds_to_generate => number() 79 | }. 80 | -export_type([t/0]). 81 | 82 | %%------------------------------------------------------------------- 83 | %% API Function Definitions 84 | %%------------------------------------------------------------------- 85 | 86 | -spec start_link(pid(), ets:tab()) -> {ok, pid()}. 87 | %% @private 88 | start_link(CategoryPid, WorkSharesTable) -> 89 | proc_lib:start_link(?MODULE, init, [{self(), [CategoryPid, WorkSharesTable]}]). 90 | 91 | -spec start(pid(), ets:tab()) -> {ok, pid()}. 92 | %% @private 93 | start(CategoryPid, WorkSharesTable) -> 94 | aequitas_work_stats_sup:start_child([CategoryPid, WorkSharesTable]). 95 | 96 | -spec generate_work_stats(pid()) -> ok. 97 | %% @private 98 | generate_work_stats(WorkStatsPid) -> 99 | WorkStatsPid ! generate_work_stats, 100 | ok. 101 | 102 | %%------------------------------------------------------------------- 103 | %% OTP Function Definitions 104 | %%------------------------------------------------------------------- 105 | 106 | -spec init({pid(), [pid(), ...]}) -> no_return(). 107 | %% @private 108 | init({Parent, [CategoryPid, WorkSharesTable]}) -> 109 | proc_lib:init_ack(Parent, {ok, self()}), 110 | Debug = sys:debug_options([]), 111 | State = 112 | #state{ 113 | category_pid = CategoryPid, 114 | category_mon = monitor(process, CategoryPid), 115 | work_shares_table = WorkSharesTable 116 | }, 117 | loop(Parent, Debug, State). 118 | 119 | -spec write_debug(io:device(), term(), term()) -> ok. 120 | %% @private 121 | write_debug(Dev, Event, Name) -> 122 | % called by sys:handle_debug(). 123 | io:format(Dev, "~p event = ~p~n", [Name, Event]). 124 | 125 | -spec system_continue(pid(), [sys:debug_opt()], state()) -> no_return(). 126 | %% @private 127 | system_continue(Parent, Debug, State) -> 128 | % http://www.erlang.org/doc/man/sys.html#Mod:system_continue-3 129 | loop(Parent, Debug, State). 130 | 131 | -spec system_terminate(term(), pid(), [sys:debug_opt()], state()) -> no_return(). 132 | %% @private 133 | system_terminate(Reason, _Parent, _Debug, _State) -> 134 | % http://www.erlang.org/doc/man/sys.html#Mod:system_terminate-4 135 | exit(Reason). 136 | 137 | -spec system_code_change(state(), ?MODULE, term(), term()) -> {ok, state()}. 138 | %% @private 139 | %% http://www.erlang.org/doc/man/sys.html#Mod:system_code_change-4 140 | system_code_change(State, _Module, _OldVsn, _Extra) -> 141 | {ok, State}. 142 | 143 | %%------------------------------------------------------------------- 144 | %% Internal Functions Definitions - Execution Loop 145 | %%------------------------------------------------------------------- 146 | 147 | loop(Parent, Debug, State) -> 148 | receive 149 | {system, From, Request} -> 150 | sys:handle_system_msg(Request, From, Parent, ?MODULE, Debug, State); 151 | Msg -> 152 | UpdatedDebug = sys:handle_debug(Debug, fun ?MODULE:write_debug/3, ?MODULE, {in, Msg}), 153 | UpdatedState = handle_nonsystem_msg(Msg, State), 154 | loop(Parent, UpdatedDebug, UpdatedState) 155 | after 156 | 5000 -> 157 | hibernate(Parent, Debug, State) 158 | end. 159 | 160 | handle_nonsystem_msg(generate_work_stats, State) -> 161 | MatchSpec = 162 | ets:fun2ms( 163 | fun ({_ActorId, Share}) when Share > 0 -> 164 | Share 165 | end), 166 | Samples = ets:select(State#state.work_shares_table, MatchSpec), 167 | WorkStats = crunch_work_stats(Samples), 168 | aequitas_category:report_work_stats(State#state.category_pid, WorkStats), 169 | State; 170 | handle_nonsystem_msg({'DOWN', Ref, process, _Pid, _Reason}, State) 171 | when Ref =:= State#state.category_mon -> 172 | exit(normal); 173 | handle_nonsystem_msg(Msg, _State) -> 174 | error({unexpected_msg, Msg}). 175 | 176 | hibernate(Parent, Debug, State) -> 177 | proc_lib:hibernate(?MODULE, system_continue, [Parent, Debug, State]). 178 | 179 | %%------------------------------------------------------------------- 180 | %% Internal Functions Definitions - Requests 181 | %%------------------------------------------------------------------- 182 | 183 | crunch_work_stats(Samples) -> 184 | StartTs = erlang:monotonic_time(nano_seconds), 185 | ActorCount = length(Samples), 186 | case ActorCount < 3 of 187 | true -> 188 | % not enough samples 189 | EndTs = erlang:monotonic_time(nano_seconds), 190 | #{ actor_count => ActorCount , 191 | seconds_to_generate => (EndTs - StartTs) / 1.0e9 192 | }; 193 | false -> 194 | SortedSamples = lists:sort(Samples), 195 | {Q2, LowerHalf, UpperHalf} = median_split(SortedSamples), 196 | {Q1, _, _} = median_split(LowerHalf), 197 | {Q3, _, _} = median_split(UpperHalf), 198 | EndTs = erlang:monotonic_time(nano_seconds), 199 | #{ actor_count => ActorCount, 200 | q1 => Q1, 201 | q2 => Q2, 202 | q3 => Q3, 203 | iqr => Q3 - Q1, 204 | seconds_to_generate => (EndTs - StartTs) / 1.0e9 205 | } 206 | end. 207 | 208 | median_split([Median]) -> 209 | {Median, [], []}; 210 | median_split(List) -> 211 | Len = length(List), 212 | HalfLen = Len div 2, 213 | case Len rem HalfLen of 214 | 0 -> 215 | {Left, Right} = lists:split(HalfLen, List), 216 | Median = (lists:last(Left) + hd(Right)) / 2, 217 | {Median, Left, Right}; 218 | 1 -> 219 | {Left, [Median | Right]} = lists:split(HalfLen, List), 220 | {Median, Left, Right} 221 | end. 222 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aequitas 2 | 3 | **This library is not under active maintenance; if you'd like to perform 4 | maintenance yourself, feel free to open an issue requesting access.** 5 | 6 | [![](https://img.shields.io/hexpm/v/aequitas.svg?style=flat)](https://hex.pm/packages/aequitas) 7 | [![](https://github.com/g-andrade/aequitas/workflows/build/badge.svg)](https://github.com/g-andrade/aequitas/actions?query=workflow%3Abuild) 8 | 9 | `aequitas` is a fairness regulator for Erlang/OTP and Elixir, with 10 | optional rate limiting capabilities. 11 | 12 | It intends on allowing fair access to limited external resources, like 13 | databases and web services, amongst distinct actors. 14 | 15 | It does so by attempting to detect outliers in ordinary workload 16 | distributions. 17 | 18 | #### Example 19 | 20 | There's a web server handling HTTP requests. We want to ensure 21 | misbehaving IP addresses don't steal (too much) system capacity from 22 | benevolent clients. 23 | 24 | We'll name our category `http_requests` and start its handler. 25 | Categories and actors can be represented by any term. 26 | 27 | ``` erlang 28 | ok = aequitas:start(http_requests). 29 | ``` 30 | 31 | Now, before we handle each HTTP request we ask `aequitas` whether an IP 32 | address can be granted work. We'll get a reply that's based on the 33 | statistical distribution of recent work allocations. 34 | 35 | ``` erlang 36 | case aequitas:ask(http_requests, IPAddress) of 37 | accepted -> 38 | Reply = handle_request(...), 39 | {200, Reply}; 40 | {rejected, _Reason} -> 41 | % too many requests! 42 | {429, <<>>} 43 | end. 44 | ``` 45 | 46 | Some more definitions of the `:ask` function exist: 47 | 48 | - `:ask(Category, ActorId, Opts)` - for 49 | [tweaking](#work-request-tweaking) 50 | - `:async_ask(Category, ActorId)` - analogous to `:ask/2` but replies 51 | asynchronously 52 | - `:async_ask(Category, ActorId, Opts)` - analogous to `:ask/3` but 53 | replies asynchronously 54 | 55 | #### Documentation and Reference 56 | 57 | Documentation and reference are hosted on 58 | [HexDocs](https://hexdocs.pm/aequitas/). 59 | 60 | #### Tested setup 61 | 62 | - Erlang/OTP 22 or higher 63 | - rebar3 64 | 65 | #### Category Tweaking 66 | 67 | The following options can be used to tweak categories, both through 68 | [static](#static-configuration) and [dynamic 69 | configuration](#dynamic-configuration). 70 | 71 | - `{max_window_size, _}` 72 | - Enforces a ceiling on how many of the last work acceptances will 73 | be [tracked](#work-tracking) 74 | - Default is 10000; value must be a positive integer or infinity 75 | - `{max_window_duration, _}` 76 | - Enforces an eventual expiration of tracked work acceptances 77 | - Default is `{seconds,5}`; value must be of type 78 | `aequitas_time_interval:t()` or infinity (see [type 79 | reference](#documentation-and-reference)) 80 | - `{min_actor_count, _}` 81 | - Establishes the requirement of a minimum amount of tracked 82 | actors before [outlier detection](#outlier-detection) is 83 | performed 84 | - Default is 30; value must be a positive integer 85 | - `{iqr_factor, _}` 86 | - IQR factor used to detect outlying actors among the workload 87 | distribution 88 | - Default is 1.5; value must be a non-negative number 89 | - `{max_collective_rate, _}` 90 | - Enforces a [collective rate limit](#collective-rate-limiting), 91 | per second, on work acceptances 92 | - Default is infinity; value must be a non-negative integer 93 | 94 | #### Work Request Tweaking 95 | 96 | The following options can be used to tweak individual work requests, 97 | i.e. in calls to either the `:ask/3` or `:async_ask/3` functions. 98 | 99 | - `{weight, _}` 100 | - [Relative weight](#work-weighing) of the work request 101 | - Default is 1; value must be a positive integer 102 | - `{min_actor_count, _}` 103 | - Overrides the `min_actor_count` configured globally for the 104 | category; its meaning remains but it only applies to the work 105 | request that overrode it 106 | - `{iqr_factor, _}` 107 | - Overrides the `iqr_factor` configured globally for the category; 108 | its meaning remains but it only applies to the work request that 109 | overrode it 110 | - `return_stats` 111 | - Return the work stats used to detect outliers together with 112 | acceptance status (see [API 113 | reference](#documentation-and-reference)) 114 | 115 | #### Collective Rate Limiting 116 | 117 | Collective rate limiting can be enabled in order to limit the total 118 | amount of work performed, per second, within a category. 119 | 120 | Once the configured limit is reached, its enforcement should tend to 121 | homogenize the number of accepted work requests per actor, even in case 122 | of very unbalanced workloads - within the reach of the configured 123 | outlier detection, that is. 124 | 125 | If the number of distinct actors tracked by the window is rather larger 126 | than the rate limit, and if `iqr_factor` is set to a very strict value, 127 | it should ultimately result in every actor performing at most one 128 | request within the period covered by the sliding window. 129 | 130 | This contrasts with impartial rate limiting which is the scope of many 131 | other libraries, whereby the acceptance/rejection ratios per actor tend 132 | to be the same, independently of how much work each actor is effectively 133 | performing. 134 | 135 | [Work weighing](#work-weighing) is taken into account when rate 136 | limiting. 137 | 138 | #### Outlier Detection 139 | 140 | The outlier detection algorithm is based on [John Tukey's 141 | fences](http://sphweb.bumc.bu.edu/otlt/MPH-Modules/BS/BS704_SummarizingData/BS704_SummarizingData7.html). 142 | 143 | The [IQR](https://en.wikipedia.org/wiki/Interquartile_range) 144 | (interquartile range) of the work shares per actor, encompassed by the 145 | work tracker, is updated continuously (although asynchronously). Based 146 | on this statistical measure, whenever there's a new request for work 147 | execution we determine whether the actor's present work share is an 148 | outlier to the right. 149 | 150 | The default threshold of outlier categorization is set to `Q3 + (1.5 x 151 | IQR)`, with IQR being `Q3 - Q1` and Q1 and Q3 being the median values of 152 | the lower and higher halves of the samples, respectively. 153 | 154 | The IQR factor can be customized, both per 155 | [category](#category-tweaking) and per [work 156 | request](#work-request-tweaking), using the `iqr_factor` setting, as 157 | detailed previously. 158 | 159 | Lower values will result in greater intolerance of high work-share 160 | outliers; higher values, the opposite. 161 | 162 | The reason for picking `1.5` as the default is rather down to convention 163 | and might not be appropriate for your specific workloads. If necessary: 164 | measure, adjust, repeat. 165 | 166 | It's possible you'll conclude the IQR technique is not adequate to solve 167 | your problem; a score of other approaches exist, with many of them being 168 | computationally (much) more expensive, - it's a trade off between 169 | correctness and availability. 170 | 171 | #### Work Weighing 172 | 173 | Work requests can be weighted by specifying the `weight` option when 174 | asking permission to execute. It must be a positive integer; the default 175 | value is `1`. 176 | 177 | Picking the web server example above, if we were to weight our requests 178 | based on their body size, it could become something similar to this: 179 | 180 | ``` erlang 181 | ReqBodySize = req_body_size(...), 182 | WorkWeight = 1 + ReqBodySize, 183 | case aequitas:ask(http_requests, IPAddress, [{weight, Weight}]) of 184 | % [...] 185 | end. 186 | ``` 187 | 188 | This way, the work share of an IP address performing a few large 189 | requests could of similar magnitude to the work share of an IP address 190 | performing many small requests. 191 | 192 | #### Work Tracking 193 | 194 | Each category is backed by a process that keeps a sliding window; this 195 | sliding window helps keep track of how many work units were attributed 196 | to each actor within the last `N` accepted requests; 197 | 198 | The size of the sliding window, `N`, is determined by constraints 199 | derived from [category settings](#category-tweaking). Whenever it gets 200 | excessively large, old events will be dropped until it is no longer so. 201 | 202 | #### Static Configuration 203 | 204 | The configuration of foreknown categories can be tweaked in `app.config` 205 | / `sys.config` by declaring the overriden settings, per category, in the 206 | following fashion: 207 | 208 | ``` erlang 209 | % ... 210 | {aequitas, 211 | [{categories, 212 | #{ http_requests => 213 | [{max_window_duration, {seconds,10}}] % Override default 5s to 10s 214 | 215 | rare_ftp_requests => 216 | [{max_window_size, 100}] % Only track up to the last 100 acceptances 217 | }} 218 | ]} 219 | % ... 220 | ``` 221 | 222 | These categories will start on boot if the configuration is valid. 223 | 224 | Proper app. configuration reloads that result in calls to the 225 | application's internal `:config_change/3` callback will trigger a reload 226 | of settings in each of the relevant category processes. 227 | 228 | #### Dynamic Configuration 229 | 230 | Reconfiguration of running categories can be performed by calling 231 | `aequitas:reconfigure/2`, e.g.: 232 | 233 | ``` erlang 234 | ok = aequitas:reconfigure(http_requests, 235 | [{max_window_duration, {seconds,10}}]). 236 | ``` 237 | 238 | (Re)configuration performed this way will override the static category 239 | configuration present in `app.config`, if any. 240 | 241 | It will also trigger a reload of settings in the relevant category 242 | process. 243 | 244 | #### License 245 | 246 | MIT License 247 | 248 | Copyright (c) 2018-2022 Guilherme Andrade 249 | 250 | Permission is hereby granted, free of charge, to any person obtaining a 251 | copy of this software and associated documentation files (the 252 | "Software"), to deal in the Software without restriction, including 253 | without limitation the rights to use, copy, modify, merge, publish, 254 | distribute, sublicense, and/or sell copies of the Software, and to 255 | permit persons to whom the Software is furnished to do so, subject to 256 | the following conditions: 257 | 258 | The above copyright notice and this permission notice shall be included 259 | in all copies or substantial portions of the Software. 260 | 261 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 262 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 263 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 264 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 265 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 266 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 267 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 268 | 269 | ----- 270 | 271 | *Generated by EDoc* 272 | -------------------------------------------------------------------------------- /doc/overview.edoc: -------------------------------------------------------------------------------- 1 | @title aequitas 2 | @doc 3 | 4 | This library is not under active maintenance; if you'd like to perform maintenance yourself, feel free to open an issue requesting access. 5 | 6 | 7 | 8 | 9 | 10 | 11 | `aequitas' is a fairness regulator for Erlang/OTP and Elixir, with optional rate limiting capabilities. 12 | 13 | It intends on allowing fair access to limited external resources, like databases and web services, 14 | amongst distinct actors. 15 | 16 | It does so by attempting to detect outliers in ordinary workload distributions. 17 | 18 |

Example

19 | 20 | There's a web server handling HTTP requests. We want to ensure misbehaving 21 | IP addresses don't steal (too much) system capacity from benevolent clients. 22 | 23 | We'll name our category `http_requests' and start its handler. Categories and 24 | actors can be represented by any term. 25 | 26 |
 27 | 
 28 | 
29 | 30 | Now, before we handle each HTTP request we ask `aequitas' whether 31 | an IP address can be granted work. We'll get a reply that's based on the 32 | statistical distribution of recent work allocations. 33 | 34 |
 35 | 
 37 |         Reply = handle_request(...),
 38 |         {200, Reply};
 39 |     {rejected, _Reason} ->
 40 |         % too many requests!
 41 |         {429, <<>>}
 42 | end.]]>
 43 | 
44 | 45 | Some more definitions of the `:ask' function exist: 46 | 57 | 58 |

Documentation and Reference

59 | 60 | Documentation and reference are hosted on HexDocs. 61 | 62 |

Tested setup

63 | 64 | 68 | 69 |

Category Tweaking

70 | 71 | The following options can be used to tweak categories, both through 72 | static and 73 | dynamic configuration. 74 | 75 | 115 | 116 |

Work Request Tweaking

117 | 118 | The following options can be used to tweak individual work requests, 119 | i.e. in calls to either the `:ask/3' or `:async_ask/3' functions. 120 | 121 | 150 | 151 |

Collective Rate Limiting

152 | 153 | Collective rate limiting can be enabled in order to limit the total amount of work 154 | performed, per second, within a category. 155 | 156 | Once the configured limit is reached, its enforcement should tend to homogenize 157 | the number of accepted work requests per actor, even in case of very unbalanced workloads 158 | - within the reach of the configured outlier detection, that is. 159 | 160 | If the number of distinct actors tracked by the window is rather larger than the rate limit, 161 | and if `iqr_factor' is set to a very strict value, it should ultimately result 162 | in every actor performing at most one request within the period covered by the sliding window. 163 | 164 | This contrasts with impartial rate limiting which is the scope of many other libraries, 165 | whereby the acceptance/rejection ratios per actor tend to be the same, independently of 166 | how much work each actor is effectively performing. 167 | 168 | Work weighing is taken into account when rate limiting. 169 | 170 |

Outlier Detection

171 | 172 | The outlier detection algorithm is based on 173 | John Tukey's fences. 174 | 175 | The IQR (interquartile range) 176 | of the work shares per actor, encompassed by the work tracker, is updated continuously (although asynchronously). 177 | Based on this statistical measure, whenever there's a new request for work execution we determine whether 178 | the actor's present work share is an outlier to the right. 179 | 180 | The default threshold of outlier categorization is set to `Q3 + (1.5 x IQR)', with IQR being `Q3 - Q1' 181 | and Q1 and Q3 being the median values of the lower and higher halves of the samples, respectively. 182 | 183 | The IQR factor can be customized, both per category 184 | and per work request, using the `iqr_factor' setting, 185 | as detailed previously. 186 | 187 | Lower values will result in greater intolerance of high work-share outliers; higher values, the opposite. 188 | 189 | The reason for picking `1.5' as the default is rather down to convention and might not be 190 | appropriate for your specific workloads. If necessary: measure, adjust, repeat. 191 | 192 | It's possible you'll conclude the IQR technique is not adequate to solve your problem; 193 | a score of other approaches exist, with many of them being computationally (much) more expensive, 194 | - it's a trade off between correctness and availability. 195 | 196 |

Work Weighing

197 | 198 | Work requests can be weighted by specifying the `weight' option when 199 | asking permission to execute. It must be a positive integer; 200 | the default value is `1'. 201 | 202 | Picking the web server example above, if we were to weight our requests 203 | based on their body size, it could become something similar to this: 204 | 205 |
206 | 
211 | 
212 | 213 | This way, the work share of an IP address performing a few large requests 214 | could of similar magnitude to the work share of an IP address performing 215 | many small requests. 216 | 217 |

Work Tracking

218 | 219 | Each category is backed by a process that keeps a sliding window; this sliding window helps 220 | keep track of how many work units were attributed to each actor within the last `N' accepted requests; 221 | 222 | The size of the sliding window, `N', is determined by constraints derived 223 | from category settings. Whenever it gets excessively large, 224 | old events will be dropped until it is no longer so. 225 | 226 |

Static Configuration

227 | 228 | The configuration of foreknown categories can be tweaked in `app.config' / `sys.config' 229 | by declaring the overriden settings, per category, in the following fashion: 230 | 231 |
232 | % ...
233 |  {aequitas,
234 |   [{categories,
235 |    #{ http_requests =>
236 |         [{max_window_duration, {seconds,10}}] % Override default 5s to 10s
237 | 
238 |       rare_ftp_requests =>
239 |         [{max_window_size, 100}] % Only track up to the last 100 acceptances
240 |    }}
241 |   ]}
242 | % ...
243 | 
244 | 245 | These categories will start on boot if the configuration is valid. 246 | 247 | Proper app. configuration reloads that result in calls to 248 | the application's internal `:config_change/3' callback will trigger 249 | a reload of settings in each of the relevant category processes. 250 | 251 |

Dynamic Configuration

252 | 253 | Reconfiguration of running categories can be performed 254 | by calling `aequitas:reconfigure/2', e.g.: 255 | 256 |
257 | ok = aequitas:reconfigure(http_requests,
258 |                           [{max_window_duration, {seconds,10}}]).
259 | 
260 | 261 | (Re)configuration performed this way will override the 262 | static category configuration present in `app.config', if any. 263 | 264 | It will also trigger a reload of settings in the relevant category process. 265 | 266 |

License

267 | 268 | MIT License 269 | 270 | Copyright (c) 2018-2022 Guilherme Andrade 271 | 272 | Permission is hereby granted, free of charge, to any person obtaining a copy 273 | of this software and associated documentation files (the "Software"), to deal 274 | in the Software without restriction, including without limitation the rights 275 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 276 | copies of the Software, and to permit persons to whom the Software is 277 | furnished to do so, subject to the following conditions: 278 | 279 | The above copyright notice and this permission notice shall be included in all 280 | copies or substantial portions of the Software. 281 | 282 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 283 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 284 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 285 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 286 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 287 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 288 | SOFTWARE. 289 | 290 | @end 291 | -------------------------------------------------------------------------------- /test/aequitas_SUITE.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2018-2022 Guilherme Andrade 2 | %% 3 | %% Permission is hereby granted, free of charge, to any person obtaining a 4 | %% copy of this software and associated documentation files (the "Software"), 5 | %% to deal in the Software without restriction, including without limitation 6 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | %% and/or sell copies of the Software, and to permit persons to whom the 8 | %% Software is furnished to do so, subject to the following conditions: 9 | %% 10 | %% The above copyright notice and this permission notice shall be included in 11 | %% all copies or substantial portions of the Software. 12 | %% 13 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO WORK SHALL THE 16 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | %% DEALINGS IN THE SOFTWARE. 20 | 21 | -module(aequitas_SUITE). 22 | -compile(export_all). 23 | 24 | -include_lib("eunit/include/eunit.hrl"). 25 | 26 | %% ------------------------------------------------------------------ 27 | %% Enumeration 28 | %% ------------------------------------------------------------------ 29 | 30 | all() -> 31 | individual_test_cases() 32 | ++ [{group, GroupName} || {GroupName, _Options, _TestCases} <- groups()]. 33 | 34 | groups() -> 35 | [{Group, [parallel], group_test_cases()} 36 | || Group <- ['10actors_20mean_10dev_1.5iqr_1sync', 37 | '10actors_20mean_10dev_1.5iqr_0sync', 38 | '100actors_200mean_100dev_1.5iqr_1sync', 39 | '100actors_200mean_100dev_1.5iqr_0sync', 40 | '1000actors_20mean_10dev_2.0iqr_1sync', 41 | '1000actors_20mean_10dev_2.0iqr_0sync', 42 | '10000actors_3mean_0dev_0.5iqr_1sync', 43 | '10000actors_3mean_0dev_0.5iqr_0sync', 44 | '10actors_100mean_20dev_3.0iqr_1sync', 45 | '10actors_100mean_20dev_3.0iqr_0sync', 46 | '100actors_10mean_0dev_10.0iqr_1sync', 47 | '100actors_10mean_0dev_10.0iqr_0sync' 48 | ]]. 49 | 50 | individual_test_cases() -> 51 | ModuleInfo = ?MODULE:module_info(), 52 | {exports, Exports} = lists:keyfind(exports, 1, ModuleInfo), 53 | [Name || {Name, 1} <- Exports, lists:suffix("_test", atom_to_list(Name))]. 54 | 55 | group_test_cases() -> 56 | ModuleInfo = ?MODULE:module_info(), 57 | {exports, Exports} = lists:keyfind(exports, 1, ModuleInfo), 58 | [Name || {Name, 1} <- Exports, lists:suffix("_grouptest", atom_to_list(Name))]. 59 | 60 | %% ------------------------------------------------------------------ 61 | %% Initialization 62 | %% ------------------------------------------------------------------ 63 | 64 | init_per_group(Group, Config) -> 65 | {ok, _} = application:ensure_all_started(aequitas), 66 | [{group, Group}] 67 | ++ group_params(Group) 68 | ++ Config. 69 | 70 | end_per_group(_Group, Config) -> 71 | ok = application:stop(aequitas), 72 | Config. 73 | 74 | group_params(Group) -> 75 | Str = atom_to_list(Group), 76 | Tokens = string:tokens(Str, [$_]), 77 | [{nr_of_actors, match_suffixed_param(Tokens, "actors")}, 78 | {nr_of_requests_mean, match_suffixed_param(Tokens, "mean")}, 79 | {nr_of_requests_stddev, match_suffixed_param(Tokens, "dev")}, 80 | {iqr_factor, match_suffixed_param(Tokens, "iqr")}, 81 | {is_sync, match_suffixed_param(Tokens, "sync") =:= 1} 82 | ]. 83 | 84 | match_suffixed_param([H|T], Suffix) -> 85 | case lists:suffix(Suffix, H) of 86 | true -> 87 | ParamStr = lists:sublist(H, length(H) - length(Suffix)), 88 | try list_to_float(ParamStr) of 89 | Float -> 90 | Float 91 | catch 92 | error:badarg -> 93 | list_to_integer(ParamStr) 94 | end; 95 | false -> 96 | match_suffixed_param(T, Suffix) 97 | end. 98 | 99 | init_per_testcase(TestCase, Config) -> 100 | {ok, _} = application:ensure_all_started(aequitas), 101 | case proplists:get_value(group, Config) of 102 | undefined -> 103 | Config; 104 | Group -> 105 | Category = {Group, TestCase}, 106 | IqrFactor = proplists:get_value(iqr_factor, Config), 107 | ok = aequitas:start( 108 | Category, [{max_window_size, infinity}, 109 | {max_window_duration, {minutes,10}}, 110 | {min_actor_count, 1}, 111 | {iqr_factor, IqrFactor} 112 | ]), 113 | [{category, Category} 114 | | Config] 115 | end. 116 | 117 | end_per_testcase(_TestCase, Config) -> 118 | case proplists:get_value(category, Config) of 119 | undefined -> 120 | Config; 121 | Category -> 122 | ok = aequitas:stop(Category), 123 | Config 124 | end. 125 | 126 | %% ------------------------------------------------------------------ 127 | %% Definition 128 | %% ------------------------------------------------------------------ 129 | 130 | static_configuration_test(_Config) -> 131 | % @see test/etc/sys.config 132 | NonAtomCategory = {static_configuration, non_atom_category}, 133 | CategoryA = static_configuration_categoryA, 134 | CategoryB = static_configuration_categoryB, 135 | CategoryC = static_configuration_categoryC, 136 | ?assertEqual({ok, 999}, aequitas_category:get_current_setting(NonAtomCategory, max_window_size)), 137 | ?assertEqual({ok, 10}, aequitas_category:get_current_setting(CategoryA, max_window_size)), 138 | ?assertEqual({ok, 42}, aequitas_category:get_current_setting(CategoryB, max_window_size)), 139 | ?assertExit({noproc, _}, aequitas_category:get_current_setting(CategoryC, max_window_size)). 140 | 141 | static_configuration_update_test(_Config) -> 142 | NonAtomCategory = {static_configuration, non_atom_category}, 143 | CategoryA = static_configuration_categoryA, 144 | CategoryB = static_configuration_categoryB, 145 | ?assertEqual({ok, 999}, aequitas_category:get_current_setting(NonAtomCategory, max_window_size)), 146 | ?assertEqual({ok, 10}, aequitas_category:get_current_setting(CategoryA, max_window_size)), 147 | ?assertEqual({ok, 42}, aequitas_category:get_current_setting(CategoryB, max_window_size)), 148 | 149 | % one category changed 150 | EnvBefore1 = full_apps_env(), 151 | {ok, SettingOptsPerCategory1} = application:get_env(aequitas, categories), 152 | SettingOptsPerCategory2 = SettingOptsPerCategory1#{ CategoryA := [{max_window_size,30}] }, 153 | ok = application:set_env(aequitas, categories, SettingOptsPerCategory2), 154 | ok = application_controller:config_change(EnvBefore1), 155 | ?assertEqual({ok, 999}, aequitas_category:get_current_setting(NonAtomCategory, max_window_size)), 156 | ?assertEqual({ok, 30}, aequitas_category:get_current_setting(CategoryA, max_window_size)), 157 | ?assertEqual({ok, 42}, aequitas_category:get_current_setting(CategoryB, max_window_size)), 158 | 159 | % one category changed 160 | EnvBefore2 = full_apps_env(), 161 | SettingOptsPerCategory3 = SettingOptsPerCategory2#{ CategoryB := [{max_window_size,50}] }, 162 | ok = application:set_env(aequitas, categories, SettingOptsPerCategory3), 163 | ok = application_controller:config_change(EnvBefore2), 164 | ?assertEqual({ok, 999}, aequitas_category:get_current_setting(NonAtomCategory, max_window_size)), 165 | ?assertEqual({ok, 30}, aequitas_category:get_current_setting(CategoryA, max_window_size)), 166 | ?assertEqual({ok, 50}, aequitas_category:get_current_setting(CategoryB, max_window_size)), 167 | 168 | % two categories changed 169 | EnvBefore3 = full_apps_env(), 170 | SettingOptsPerCategory4 = SettingOptsPerCategory1, 171 | ok = application:set_env(aequitas, categories, SettingOptsPerCategory4), 172 | ok = application_controller:config_change(EnvBefore3), 173 | ?assertEqual({ok, 999}, aequitas_category:get_current_setting(NonAtomCategory, max_window_size)), 174 | ?assertEqual({ok, 10}, aequitas_category:get_current_setting(CategoryA, max_window_size)), 175 | ?assertEqual({ok, 42}, aequitas_category:get_current_setting(CategoryB, max_window_size)), 176 | 177 | % no change at all 178 | EnvBefore4 = full_apps_env(), 179 | SettingOptsPerCategory5 = SettingOptsPerCategory4, 180 | ok = application:set_env(aequitas, categories, SettingOptsPerCategory5), 181 | ok = application_controller:config_change(EnvBefore4), 182 | ?assertEqual({ok, 999}, aequitas_category:get_current_setting(NonAtomCategory, max_window_size)), 183 | ?assertEqual({ok, 10}, aequitas_category:get_current_setting(CategoryA, max_window_size)), 184 | ?assertEqual({ok, 42}, aequitas_category:get_current_setting(CategoryB, max_window_size)). 185 | 186 | static_configuration_override_test(_Config) -> 187 | Category = static_configuration_categoryA, 188 | ?assertEqual({ok, 10}, aequitas_category:get_current_setting(Category, max_window_size)), 189 | ok = aequitas:reconfigure(Category, [{max_window_size, 20}]), 190 | ?assertEqual({ok, 20}, aequitas_category:get_current_setting(Category, max_window_size)). 191 | 192 | dynamic_reconfiguration_test(_Config) -> 193 | Category = dynamic_configuration_category, 194 | CategoryOpts = [{max_window_size, 23}], 195 | ok = aequitas:start(Category, CategoryOpts), 196 | ?assertEqual({ok, 23}, aequitas_category:get_current_setting(Category, max_window_size)), 197 | ok = aequitas:reconfigure(Category, [{max_window_size, 46}]), 198 | ?assertEqual({ok, 46}, aequitas_category:get_current_setting(Category, max_window_size)). 199 | 200 | rate_limited_acceptances_test(_Config) -> 201 | Category = rate_limited_acceptances_test, 202 | ExpectedRate = 200, 203 | CategoryOpts = 204 | [{min_actor_count, 1 bsl 128}, % disable outlier detection entirely 205 | {max_collective_rate, ExpectedRate} 206 | ], 207 | ok = aequitas:start(Category, CategoryOpts), 208 | 209 | Self = self(), 210 | DurationSeconds = 3, 211 | Duration = timer:seconds(DurationSeconds), 212 | NrOfActors = 100, 213 | WorkerPid = spawn(fun () -> rate_limit_test_worker(Self, Category, NrOfActors, Duration) end), 214 | WorkerMon = monitor(process, WorkerPid), 215 | receive 216 | {WorkerPid, CountPerStatus} -> 217 | {ok, AcceptedCount} = dict:find(accepted, CountPerStatus), 218 | AcceptedRate = AcceptedCount / DurationSeconds, 219 | Ratio = AcceptedRate / ExpectedRate, 220 | ct:pal("AcceptedRate: ~p", [AcceptedRate]), 221 | ct:pal("ExpectedRate: ~p", [ExpectedRate]), 222 | ct:pal("Ratio: ~p", [Ratio]), 223 | ?assert(Ratio >= 0.75), 224 | ?assert(Ratio =< 1.25), 225 | ok = aequitas:stop(Category); 226 | {'DOWN', WorkerMon, process, _Pid, Reason} -> 227 | error({worker_died, Reason}) 228 | end. 229 | 230 | rate_unlimited_acceptances_test(_Config) -> 231 | Category = rate_unlimited_acceptances_test, 232 | CategoryOpts = 233 | [{min_actor_count, 1 bsl 128}, % disable outlier detection entirely 234 | {max_collective_rate, infinity} 235 | ], 236 | ok = aequitas:start(Category, CategoryOpts), 237 | 238 | Self = self(), 239 | DurationSeconds = 3, 240 | Duration = timer:seconds(DurationSeconds), 241 | NrOfActors = 100, 242 | WorkerPid = spawn(fun () -> rate_limit_test_worker(Self, Category, NrOfActors, Duration) end), 243 | WorkerMon = monitor(process, WorkerPid), 244 | receive 245 | {WorkerPid, CountPerStatus} -> 246 | ?assertEqual(error, dict:find({rejected,outlier}, CountPerStatus)), 247 | ?assertEqual(error, dict:find({rejected,rate_limited}, CountPerStatus)), 248 | ok = aequitas:stop(Category); 249 | {'DOWN', WorkerMon, process, _Pid, Reason} -> 250 | error({worker_died, Reason}) 251 | end. 252 | 253 | correct_iqr_enforcement_grouptest(Config) -> 254 | NrOfActors = proplists:get_value(nr_of_actors, Config), 255 | NrOfRequestsMean = proplists:get_value(nr_of_requests_mean, Config), 256 | NrOfRequestsStdDev = proplists:get_value(nr_of_requests_stddev, Config), 257 | ActorRequests = 258 | lists:foldl( 259 | fun (Actor, Acc) -> 260 | NrOfRequests = max(0, round(NrOfRequestsMean + (NrOfRequestsStdDev * rand:normal()))), 261 | [Actor || _ <- lists:seq(1, NrOfRequests)] 262 | ++ Acc 263 | end, 264 | [], lists:seq(1, NrOfActors)), 265 | ShuffledActorRequests = 266 | lists_shuffle(ActorRequests), 267 | correct_iqr_enforcement_grouptest(ShuffledActorRequests, Config, #{}). 268 | 269 | %% ------------------------------------------------------------------ 270 | %% Internal 271 | %% ------------------------------------------------------------------ 272 | 273 | full_apps_env() -> 274 | All = [App || {App, _Desc, _Vsn} <- application:which_applications()], 275 | [{App, application:get_all_env(App)} || App <- All]. 276 | 277 | rate_limit_test_worker(Parent, Category, NrOfActors, Duration) -> 278 | erlang:send_after(Duration, self(), finished), 279 | rate_limit_test_worker_loop(Parent, Category, NrOfActors, dict:new()). 280 | 281 | rate_limit_test_worker_loop(Parent, Category, NrOfActors, Acc) -> 282 | ActorId = rand:uniform(NrOfActors), 283 | {Tag, Mon} = aequitas:async_ask(Category, ActorId), 284 | receive 285 | finished -> 286 | Parent ! {self(), Acc}; 287 | {Tag, Result} -> 288 | demonitor(Mon, [flush]), 289 | UpdatedAcc = dict:update_counter(Result, +1, Acc), 290 | rate_limit_test_worker_loop(Parent, Category, NrOfActors, UpdatedAcc); 291 | {'DOWN', Mon, process, _Pid, Reason} -> 292 | exit({category_died, Reason}) 293 | end. 294 | 295 | correct_iqr_enforcement_grouptest([Actor | NextActors], Config, WorkShares) -> 296 | Category = proplists:get_value(category, Config), 297 | AskOpts = [return_stats], 298 | 299 | {AskResult, Stats} = ask(Category, Actor, AskOpts, Config), 300 | IqrFactor = proplists:get_value(iqr_factor, Config), 301 | ExpectedAskResult = expected_ask_result(Actor, IqrFactor, WorkShares, Stats), 302 | ?assertEqual(ExpectedAskResult, AskResult), 303 | 304 | UpdatedWorkShares = 305 | case AskResult of 306 | accepted -> 307 | WorkShare = maps:get(Actor, WorkShares, 0), 308 | WorkShares#{ Actor => WorkShare + 1 }; 309 | {rejected,_Reason} -> 310 | WorkShares 311 | end, 312 | correct_iqr_enforcement_grouptest(NextActors, Config, UpdatedWorkShares); 313 | correct_iqr_enforcement_grouptest([], _Config, _WorkShares) -> 314 | ok. 315 | 316 | ask(Category, Actor, AskOpts, Config) -> 317 | case proplists:get_value(is_sync, Config) of 318 | true -> 319 | aequitas:ask(Category, Actor, AskOpts); 320 | false -> 321 | {Tag, Monitor} = aequitas:async_ask(Category, Actor, AskOpts), 322 | receive 323 | {Tag, Result} -> 324 | demonitor(Monitor, [flush]), 325 | Result; 326 | {'DOWN', Monitor, process, _Pid, Reason} -> 327 | error({category_died, Reason}) 328 | end 329 | end. 330 | 331 | expected_ask_result(Actor, IqrFactor, WorkShares, Stats) -> 332 | case Stats of 333 | #{ q3 := Q3, iqr := IQR } -> 334 | WorkShare = maps:get(Actor, WorkShares, 0), 335 | WorkLimit = Q3 + (IQR * IqrFactor), 336 | case WorkShare > WorkLimit of 337 | true -> {rejected,outlier}; 338 | false -> accepted 339 | end; 340 | #{} -> 341 | accepted 342 | end. 343 | 344 | lists_shuffle(List) -> 345 | WithTags = [{V, rand:uniform()} || V <- List], 346 | Sorted = lists:keysort(2, WithTags), 347 | [V || {V, _Tag} <- Sorted]. 348 | -------------------------------------------------------------------------------- /src/aequitas_category.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2018-2022 Guilherme Andrade 2 | %% 3 | %% Permission is hereby granted, free of charge, to any person obtaining a 4 | %% copy of this software and associated documentation files (the "Software"), 5 | %% to deal in the Software without restriction, including without limitation 6 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | %% and/or sell copies of the Software, and to permit persons to whom the 8 | %% Software is furnished to do so, subject to the following conditions: 9 | %% 10 | %% The above copyright notice and this permission notice shall be included in 11 | %% all copies or substantial portions of the Software. 12 | %% 13 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO WORK SHALL THE 16 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | %% DEALINGS IN THE SOFTWARE. 20 | 21 | %% @reference Standard Score / Z-Score (Wikpiedia) 22 | %% @reference T-Score vs. Z-score (statisticshowto.com) 23 | %% @reference Detection of Outliers (Wikipedia) 24 | %% @reference Robust measures of scale (Wikipedia) 25 | %% @reference Anomaly Detection with Robust Zscore (pkghosh.wordpress.com) 26 | %% @reference Three ways to detect outliers (colingorrie.github.io) 27 | %% @reference Interquartile range (Wikipedia) 28 | 29 | -module(aequitas_category). 30 | 31 | -ifdef(HIPE_SUPPORTED). 32 | -compile([native]). 33 | -endif. 34 | 35 | % https://gist.github.com/marcelog/97708058cd17f86326c82970a7f81d40#file-simpleproc-erl 36 | 37 | %%------------------------------------------------------------------- 38 | %% API Function Exports 39 | %%------------------------------------------------------------------- 40 | 41 | -export( 42 | [start_link/3, 43 | start/3, 44 | stop/1, 45 | ask/3, 46 | async_ask/3, 47 | update_settings/2, 48 | async_reload_settings/1, 49 | report_work_stats/2 50 | ]). 51 | 52 | -ignore_xref( 53 | [start_link/3 54 | ]). 55 | 56 | -ifdef(TEST). 57 | -export( 58 | [get_current_setting/2 59 | ]). 60 | -endif. 61 | 62 | %%------------------------------------------------------------------- 63 | %% OTP Function Exports 64 | %%------------------------------------------------------------------- 65 | 66 | -export( 67 | [init/1, 68 | system_code_change/4, 69 | system_continue/3, 70 | system_terminate/4, 71 | write_debug/3 72 | ]). 73 | 74 | -ignore_xref( 75 | [init/1, 76 | system_code_change/4, 77 | system_continue/3, 78 | system_terminate/4, 79 | write_debug/3 80 | ]). 81 | 82 | %%------------------------------------------------------------------- 83 | %% Macro Definitions 84 | %%------------------------------------------------------------------- 85 | 86 | -define(DEFAULT_MAX_WINDOW_SIZE, 10000). 87 | -define(DEFAULT_MAX_WINDOW_DURATION, 5000). 88 | -define(DEFAULT_MIN_ACTOR_COUNT, 30). 89 | -define(DEFAULT_IQR_FACTOR, 1.5). 90 | -define(DEFAULT_MAX_COLLECTIVE_RATE, infinity). 91 | 92 | -define(DEFAULT_WORK_WEIGHT, 1). 93 | -define(DEFAULT_RETURN_STATS, false). 94 | 95 | -define(COLL_LIMITER_UPDATE_PERIOD, 500). % in milliseconds 96 | -define(COLL_LIMITER_MAX_DESIRE_HISTORY_SZ, 4). 97 | 98 | -define(HIBERNATION_TIMEOUT, 5000). 99 | 100 | -define(is_pos_integer(V), (is_integer((V)) andalso ((V) > 0))). 101 | -define(is_non_neg_integer(V), (is_integer((V)) andalso ((V) >= 0))). 102 | -define(is_non_neg_number(V), (is_number((V)) andalso ((V) >= 0))). 103 | 104 | %%------------------------------------------------------------------- 105 | %% Record and Type Definitions 106 | %%------------------------------------------------------------------- 107 | 108 | -record(settings, { 109 | max_window_size :: pos_integer() | infinity, 110 | max_window_duration :: pos_integer() | infinity, 111 | min_actor_count :: pos_integer(), 112 | iqr_factor :: number(), 113 | max_collective_rate :: non_neg_integer() | infinity 114 | }). 115 | -type settings() :: #settings{}. 116 | 117 | -record(work, { 118 | actor_id :: term(), 119 | weight :: pos_integer(), 120 | timestamp :: integer() 121 | }). 122 | -type work() :: #work{}. 123 | 124 | -record(coll_limiter, { 125 | capacity :: non_neg_integer() | infinity, 126 | accepted :: non_neg_integer(), 127 | rejected :: non_neg_integer(), 128 | desire_history :: non_neg_integer(), 129 | desire_history_size :: non_neg_integer() 130 | }). 131 | -type coll_limiter() :: #coll_limiter{}. 132 | 133 | -record(state, { 134 | category :: term(), % the category identifier 135 | settings :: settings(), % the category settings 136 | %% 137 | window :: queue:queue(work()), % sliding window 138 | window_size :: non_neg_integer(), % queue:len/1 is expensive 139 | %% 140 | work_shares_table :: ets:tab(), 141 | work_stats_status :: updated | outdated | updating, 142 | work_stats :: aequitas_work_stats:t(), 143 | %% 144 | work_stats_pid :: pid(), 145 | work_stats_mon :: reference(), 146 | %% 147 | coll_limiter :: coll_limiter() 148 | }). 149 | -type state() :: #state{}. 150 | 151 | -type ask_params() :: 152 | #{ weight => pos_integer(), 153 | return_stats => boolean() 154 | }. 155 | 156 | -type setting_opt() :: 157 | {max_window_size, pos_integer() | infinity} | 158 | {max_window_duration, aequitas_time_interval:t() | infinity} | 159 | {min_actor_count, pos_integer()} | 160 | {iqr_factor, number()} | 161 | {max_collective_rate, non_neg_integer()}. 162 | -export_type([setting_opt/0]). 163 | 164 | -type ask_opt() :: 165 | {weight, pos_integer()} | 166 | {min_actor_count, pos_integer()} | 167 | {iqr_factor, number()} | 168 | return_stats. 169 | -export_type([ask_opt/0]). 170 | 171 | %%------------------------------------------------------------------- 172 | %% API Function Definitions 173 | %%------------------------------------------------------------------- 174 | 175 | -spec start_link(term(), boolean(), [setting_opt()]) -> {ok, pid()} | {error, {already_started,pid()}}. 176 | %% @private 177 | start_link(Category, SaveSettings, SettingOpts) -> 178 | Args = [{self(), [Category, SaveSettings, SettingOpts]}], 179 | Timeout = infinity, 180 | Opts = [], 181 | proc_lib:start_link(?MODULE, init, Args, Timeout, Opts). 182 | 183 | -spec start(term(), boolean(), [setting_opt()]) 184 | -> {ok, pid()} | 185 | {error, {invalid_setting_opt | invalid_setting_opts, _}} | 186 | {error, {already_started, pid()}}. 187 | %% @private 188 | start(Category, SaveSettings, SettingOpts) -> 189 | case validate_settings(SettingOpts) of 190 | ok -> 191 | aequitas_category_sup:start_child([Category, SaveSettings, SettingOpts]); 192 | {error, Reason} -> 193 | {error, Reason} 194 | end. 195 | 196 | -spec stop(term()) -> ok | {error, not_started}. 197 | %% @private 198 | stop(Category) -> 199 | Pid = whereis_server(Category), 200 | {Tag, Mon} = send_call(Pid, stop), 201 | wait_call_reply(Tag, Mon). 202 | 203 | -spec ask(term(), term(), [ask_opt()]) 204 | -> Status | {Status, Stats} | {error, Reason} 205 | when Status :: accepted | {rejected, RejectionReason}, 206 | RejectionReason :: outlier | rate_limited, 207 | Stats :: aequitas_work_stats:t(), 208 | Reason :: not_started. 209 | %% @private 210 | ask(Category, ActorId, Opts) -> 211 | {Tag, Mon} = async_ask(Category, ActorId, Opts), 212 | wait_call_reply(Tag, Mon). 213 | 214 | -spec async_ask(term(), term(), [ask_opt()]) -> {reference(), reference()}. 215 | %% @private 216 | async_ask(Category, ActorId, Opts) -> 217 | Params = parse_ask_opts(Opts), 218 | Pid = whereis_server(Category), 219 | send_call(Pid, {ask, ActorId, Params}). 220 | 221 | -spec update_settings(term(), [setting_opt()]) 222 | -> ok | {error, not_started | {invalid_setting_opt | invalid_setting_opts, _}}. 223 | %% @private 224 | update_settings(Category, SettingOpts) -> 225 | case validate_settings(SettingOpts) of 226 | ok -> 227 | aequitas_cfg:category_set(Category, SettingOpts), 228 | reload_settings(Category); 229 | {error, Reason} -> 230 | {error, Reason} 231 | end. 232 | 233 | -spec async_reload_settings(pid()) -> ok. 234 | %% @private 235 | async_reload_settings(Pid) -> 236 | send_cast(Pid, reload_settings). 237 | 238 | -spec report_work_stats(pid(), aequitas_work_stats:t()) -> ok. 239 | %% @private 240 | report_work_stats(Pid, WorkStats) -> 241 | send_cast(Pid, {report_work_stats, WorkStats}). 242 | 243 | -ifdef(TEST). 244 | %% @private 245 | get_current_setting(Category, Key) -> 246 | Pid = whereis_server(Category), 247 | State = sys:get_state(Pid), 248 | Settings = State#state.settings, 249 | case Key of 250 | max_window_size -> 251 | {ok, Settings#settings.max_window_size} 252 | end. 253 | -endif. 254 | 255 | %%------------------------------------------------------------------- 256 | %% OTP Function Definitions 257 | %%------------------------------------------------------------------- 258 | 259 | -spec init({pid(), [term(), ...]}) -> no_return(). 260 | %% @private 261 | init({Parent, [Category, SaveSettings, SettingOpts]}) -> 262 | Debug = sys:debug_options([]), 263 | Server = server_name(Category), 264 | case aequitas_proc_reg:register(Server, self()) of 265 | ok -> 266 | _ = SaveSettings andalso aequitas_cfg:category_set(Category, SettingOpts), 267 | Settings = load_settings(Category), 268 | WorkSharesTable = ets:new(work_shares, [protected, {read_concurrency,true}]), 269 | {ok, WorkStatsPid} = aequitas_work_stats:start(self(), WorkSharesTable), 270 | proc_lib:init_ack(Parent, {ok, self()}), 271 | 272 | State = 273 | #state{ 274 | category = Category, 275 | settings = Settings, 276 | %% 277 | window = queue:new(), 278 | window_size = 0, 279 | %% 280 | work_shares_table = WorkSharesTable, 281 | work_stats_status = updated, 282 | work_stats = #{ actor_count => 0, seconds_to_generate => 0 }, 283 | %% 284 | work_stats_pid = WorkStatsPid, 285 | work_stats_mon = monitor(process, WorkStatsPid), 286 | %% 287 | coll_limiter = initial_coll_limiter(Settings) 288 | }, 289 | 290 | _ = schedule_coll_limiter_capacity_replenishment( 291 | ?COLL_LIMITER_UPDATE_PERIOD, erlang:monotonic_time()), 292 | loop(Parent, Debug, State); 293 | {error, {already_registered, ExistingPid}} -> 294 | proc_lib:init_ack(Parent, {error, {already_started, ExistingPid}}) 295 | end. 296 | 297 | -spec write_debug(io:device(), term(), term()) -> ok. 298 | %% @private 299 | write_debug(Dev, Event, Name) -> 300 | % called by sys:handle_debug(). 301 | io:format(Dev, "~p event = ~p~n", [Name, Event]). 302 | 303 | -spec system_continue(pid(), [sys:debug_opt()], state()) -> no_return(). 304 | %% @private 305 | system_continue(Parent, Debug, State) -> 306 | % http://www.erlang.org/doc/man/sys.html#Mod:system_continue-3 307 | loop(Parent, Debug, State). 308 | 309 | -spec system_terminate(term(), pid(), [sys:debug_opt()], state()) -> no_return(). 310 | %% @private 311 | system_terminate(Reason, _Parent, _Debug, _State) -> 312 | % http://www.erlang.org/doc/man/sys.html#Mod:system_terminate-4 313 | exit(Reason). 314 | 315 | -spec system_code_change(state(), ?MODULE, term(), term()) -> {ok, state()}. 316 | %% http://www.erlang.org/doc/man/sys.html#Mod:system_code_change-4 317 | %% @private 318 | system_code_change(State, _Module, _OldVsn, _Extra) -> 319 | {ok, State}. 320 | 321 | %%------------------------------------------------------------------- 322 | %% Internal Functions Definitions - Initialization and Requests 323 | %%------------------------------------------------------------------- 324 | 325 | whereis_server(Category) -> 326 | Server = server_name(Category), 327 | aequitas_proc_reg:whereis(Server). 328 | 329 | -spec validate_settings([setting_opt()]) -> ok | {error, term()}. 330 | validate_settings(SettingOpts) -> 331 | case parse_settings_opts(SettingOpts) of 332 | {ok, _Settings} -> 333 | ok; 334 | {error, Reason} -> 335 | {error, Reason} 336 | end. 337 | 338 | -spec reload_settings(term()) -> ok | {error, not_started}. 339 | reload_settings(Category) -> 340 | Pid = whereis_server(Category), 341 | {Tag, Mon} = send_call(Pid, reload_settings), 342 | wait_call_reply(Tag, Mon). 343 | 344 | server_name(Category) -> 345 | {?MODULE, Category}. 346 | 347 | load_settings(Category) -> 348 | SettingOpts = aequitas_cfg:category_get(Category, []), 349 | case parse_settings_opts(SettingOpts) of 350 | {ok, Settings} -> 351 | Settings; 352 | {error, Reason} -> 353 | error(#{ category => Category, reason => Reason }) 354 | end. 355 | 356 | parse_settings_opts(SettingOpts) -> 357 | DefaultSettings = 358 | #settings{ max_window_size = ?DEFAULT_MAX_WINDOW_SIZE, 359 | max_window_duration = ?DEFAULT_MAX_WINDOW_DURATION, 360 | min_actor_count = ?DEFAULT_MIN_ACTOR_COUNT, 361 | iqr_factor = ?DEFAULT_IQR_FACTOR, 362 | max_collective_rate = ?DEFAULT_MAX_COLLECTIVE_RATE 363 | }, 364 | parse_settings_opts(SettingOpts, DefaultSettings). 365 | 366 | parse_settings_opts([{max_window_size, MaxWindowSize} | Next], Acc) 367 | when ?is_pos_integer(MaxWindowSize); MaxWindowSize =:= infinity -> 368 | parse_settings_opts( 369 | Next, Acc#settings{ max_window_size = MaxWindowSize } 370 | ); 371 | parse_settings_opts([{max_window_duration, MaxWindowDuration} = Opt | Next], Acc) -> 372 | case MaxWindowDuration =:= infinity orelse 373 | aequitas_time_interval:to_milliseconds(MaxWindowDuration) 374 | of 375 | true -> 376 | parse_settings_opts( 377 | Next, Acc#settings{ max_window_duration = MaxWindowDuration } 378 | ); 379 | {ok, Milliseconds} when Milliseconds > 0 -> 380 | parse_settings_opts( 381 | Next, Acc#settings{ max_window_duration = Milliseconds } 382 | ); 383 | _ -> 384 | {error, {invalid_setting_opt, Opt}} 385 | end; 386 | parse_settings_opts([{min_actor_count, MinActorCount} | Next], Acc) 387 | when ?is_pos_integer(MinActorCount) -> 388 | parse_settings_opts( 389 | Next, Acc#settings{ min_actor_count = MinActorCount } 390 | ); 391 | parse_settings_opts([{iqr_factor, IqrFactor} | Next], Acc) 392 | when ?is_non_neg_number(IqrFactor) -> 393 | parse_settings_opts( 394 | Next, Acc#settings{ iqr_factor = IqrFactor } 395 | ); 396 | parse_settings_opts([{max_collective_rate, MaxCollectiveRate} | Next], Acc) 397 | when ?is_non_neg_integer(MaxCollectiveRate); 398 | MaxCollectiveRate =:= infinity -> 399 | parse_settings_opts( 400 | Next, Acc#settings{ max_collective_rate = MaxCollectiveRate } 401 | ); 402 | parse_settings_opts([], Acc) -> 403 | {ok, Acc}; 404 | parse_settings_opts([InvalidOpt | _Next], _Acc) -> 405 | {error, {invalid_setting_opt, InvalidOpt}}; 406 | parse_settings_opts(InvalidOpts, _Acc) -> 407 | {error, {invalid_setting_opts, InvalidOpts}}. 408 | 409 | send_call(undefined, _Call) -> 410 | FakeMon = make_ref(), 411 | Tag = FakeMon, 412 | self() ! {'DOWN', FakeMon, process, undefined, noproc}, 413 | {FakeMon, Tag}; 414 | send_call(Pid, Call) -> 415 | Mon = monitor(process, Pid), 416 | Tag = Mon, 417 | Pid ! {call, self(), Tag, Call}, 418 | {Tag, Mon}. 419 | 420 | send_cast(undefined, _Cast) -> 421 | ok; 422 | send_cast(Pid, Cast) -> 423 | Pid ! {cast, Cast}, 424 | ok. 425 | 426 | wait_call_reply(Tag, Mon) -> 427 | receive 428 | {Tag, Reply} -> 429 | demonitor(Mon, [flush]), 430 | Reply; 431 | {'DOWN', Mon, process, _Pid, Reason} when Reason =:= normal; 432 | Reason =:= shutdown; 433 | Reason =:= noproc -> 434 | {error, not_started}; 435 | {'DOWN', Mon, process, _Pid, Reason} -> 436 | error({category_process, Reason}) 437 | end. 438 | 439 | %%------------------------------------------------------------------- 440 | %% Internal Functions Definitions - Execution Loop 441 | %%------------------------------------------------------------------- 442 | 443 | loop(Parent, Debug, State) when State#state.work_stats_status =:= outdated -> 444 | WorkStatsPid = State#state.work_stats_pid, 445 | aequitas_work_stats:generate_work_stats(WorkStatsPid), 446 | UpdatedState = set_work_stats_status(updating, State), 447 | loop(Parent, Debug, UpdatedState); 448 | loop(Parent, Debug, State) -> 449 | case loop_action(State) of 450 | simple -> 451 | receive 452 | Msg -> 453 | handle_msg(Msg, Parent, Debug, State) 454 | after 455 | ?HIBERNATION_TIMEOUT -> 456 | hibernate(Parent, Debug, State) 457 | end; 458 | {drop, Work} -> 459 | UpdatedState = drop_work(Work, State), 460 | loop(Parent, Debug, UpdatedState); 461 | {drop_after, Work, WaitTime} -> 462 | receive 463 | Msg -> 464 | handle_msg(Msg, Parent, Debug, State) 465 | after 466 | WaitTime -> 467 | UpdatedState = drop_work(Work, State), 468 | loop(Parent, Debug, UpdatedState) 469 | end 470 | end. 471 | 472 | loop_action(State) -> 473 | WorkPeek = queue:peek(State#state.window), 474 | Settings = State#state.settings, 475 | loop_action(WorkPeek, Settings, State). 476 | 477 | loop_action({value, Work}, Settings, State) 478 | when Settings#settings.max_window_size < State#state.window_size -> 479 | {drop, Work}; 480 | loop_action({value, Work}, Settings, _State) 481 | when Settings#settings.max_window_duration =/= infinity -> 482 | Now = erlang:monotonic_time(milli_seconds), 483 | ExpirationTs = Work#work.timestamp + Settings#settings.max_window_duration, 484 | WaitTime = ExpirationTs - Now, 485 | case WaitTime =< 0 of 486 | true -> 487 | {drop, Work}; 488 | _ -> 489 | {drop_after, Work, WaitTime} 490 | end; 491 | loop_action(_WorkPeek, _Settings, _State) -> 492 | simple. 493 | 494 | drop_work(Work, State) -> 495 | UpdatedWindow = queue:drop(State#state.window), 496 | UpdatedWindowSize = State#state.window_size - 1, 497 | update_work_share(State#state.work_shares_table, Work#work.actor_id, -Work#work.weight), 498 | UpdatedState = 499 | State#state{ 500 | window = UpdatedWindow, 501 | window_size = UpdatedWindowSize 502 | }, 503 | set_work_stats_status(outdated, UpdatedState). 504 | 505 | handle_msg({system, From, Request}, Parent, Debug, State) -> 506 | sys:handle_system_msg(Request, From, Parent, ?MODULE, Debug, State); 507 | handle_msg(Msg, Parent, Debug, State) -> 508 | UpdatedDebug = sys:handle_debug(Debug, fun ?MODULE:write_debug/3, ?MODULE, {in, Msg}), 509 | handle_nonsystem_msg(Msg, Parent, UpdatedDebug, State). 510 | 511 | handle_nonsystem_msg({call, Pid, Tag, {ask, ActorId, AskParams}}, Parent, Debug, State) -> 512 | {Reply, UpdatedState} = handle_ask(ActorId, AskParams, State), 513 | Pid ! {Tag, Reply}, 514 | loop(Parent, Debug, UpdatedState); 515 | handle_nonsystem_msg({call, Pid, Tag, reload_settings}, Parent, Debug, State) -> 516 | UpdatedState = handle_settings_reload(State), 517 | Pid ! {Tag, ok}, 518 | loop(Parent, Debug, UpdatedState); 519 | handle_nonsystem_msg({call, Pid, Tag, stop}, _Parent, _Debug, _State) -> 520 | Pid ! {Tag, ok}, 521 | exit(normal); 522 | handle_nonsystem_msg({cast, reload_settings}, Parent, Debug, State) -> 523 | UpdatedState = handle_settings_reload(State), 524 | loop(Parent, Debug, UpdatedState); 525 | handle_nonsystem_msg({cast, {report_work_stats, WorkStats}}, Parent, Debug, State) -> 526 | State2 = State#state{ work_stats = WorkStats }, 527 | State3 = set_work_stats_status(updated, State2), 528 | loop(Parent, Debug, State3); 529 | handle_nonsystem_msg({replenish_coll_limiter_capacity, LastReplenishTs}, Parent, Debug, State) -> 530 | Settings = State#state.settings, 531 | CollLimiter = State#state.coll_limiter, 532 | UpdatedCollLimiter = replenish_coll_limiter_capacity(LastReplenishTs, Settings, CollLimiter), 533 | UpdatedState = State#state{ coll_limiter = UpdatedCollLimiter }, 534 | loop(Parent, Debug, UpdatedState); 535 | handle_nonsystem_msg(Msg, _Parent, _Debug, _State) -> 536 | error({unexpected_msg, Msg}). 537 | 538 | handle_settings_reload(State) -> 539 | State#state{ 540 | settings = load_settings(State#state.category) 541 | }. 542 | 543 | hibernate(Parent, Debug, State) -> 544 | proc_lib:hibernate(?MODULE, system_continue, [Parent, Debug, State]). 545 | 546 | %%------------------------------------------------------------------- 547 | %% Internal Functions Definitions - Asking 548 | %%------------------------------------------------------------------- 549 | 550 | -spec parse_ask_opts([ask_opt()]) -> ask_params(). 551 | parse_ask_opts(Opts) -> 552 | parse_ask_opts(Opts, #{}). 553 | 554 | parse_ask_opts([{weight, Weight} | Next], Acc) 555 | when ?is_pos_integer(Weight) -> 556 | parse_ask_opts( 557 | Next, Acc#{ weight => Weight } 558 | ); 559 | parse_ask_opts([return_stats | Next], Acc) -> 560 | parse_ask_opts( 561 | Next, Acc#{ return_stats => true } 562 | ); 563 | parse_ask_opts([{min_actor_count, MinActorCount} | Next], Acc) 564 | when ?is_pos_integer(MinActorCount) -> 565 | parse_ask_opts( 566 | Next, Acc#{ min_actor_count => MinActorCount } 567 | ); 568 | parse_ask_opts([{iqr_factor, IqrFactor} | Next], Acc) 569 | when ?is_non_neg_number(IqrFactor) -> 570 | parse_ask_opts( 571 | Next, Acc#{ iqr_factor => IqrFactor } 572 | ); 573 | parse_ask_opts([], Acc) -> 574 | Acc; 575 | parse_ask_opts([InvalidOpt|_], _Acc) -> 576 | error({badarg, InvalidOpt}); 577 | parse_ask_opts(InvalidOpts, _Acc) -> 578 | error({badarg, InvalidOpts}). 579 | 580 | handle_ask(ActorId, AskParams, State) -> 581 | Now = erlang:monotonic_time(milli_seconds), 582 | Weight = maps:get(weight, AskParams, ?DEFAULT_WORK_WEIGHT), 583 | CollLimiter = State#state.coll_limiter, 584 | 585 | case has_reached_work_limit(ActorId, AskParams, State) orelse 586 | check_collective_limit(Weight, CollLimiter) 587 | of 588 | true -> 589 | maybe_return_stats_in_ask(AskParams, {rejected, outlier}, State); 590 | no -> 591 | State2 = update_coll_limiter_rejections(Weight, State), 592 | maybe_return_stats_in_ask(AskParams, {rejected, rate_limited}, State2); 593 | _ -> 594 | State3 = accept(ActorId, Weight, Now, State), 595 | State4 = update_coll_limiter_acceptances(Weight, State3), 596 | maybe_return_stats_in_ask(AskParams, accepted, State4) 597 | end. 598 | 599 | maybe_return_stats_in_ask(#{ return_stats := true }, Status, State) -> 600 | {{Status, State#state.work_stats}, State}; 601 | maybe_return_stats_in_ask(_AskParams, Status, State) -> 602 | {Status, State}. 603 | 604 | has_reached_work_limit(ActorId, AskParams, State) -> 605 | Settings = State#state.settings, 606 | MinActorCount = maps:get(min_actor_count, AskParams, Settings#settings.min_actor_count), 607 | case State#state.work_stats of 608 | #{ actor_count := ActorCount, q3 := Q3, iqr := IQR } when ActorCount >= MinActorCount -> 609 | CurrentWorkShare = current_work_share(ActorId, State), 610 | IqrFactor = iqr_factor(AskParams, State), 611 | CurrentWorkShare > (Q3 + (IQR * IqrFactor)); 612 | _ -> 613 | % not enough samples 614 | false 615 | end. 616 | 617 | current_work_share(ActorId, State) -> 618 | WorkSharesTable = State#state.work_shares_table, 619 | case ets:lookup(WorkSharesTable, ActorId) of 620 | [{_, WorkShare}] -> 621 | WorkShare; 622 | [] -> 623 | 0 624 | end. 625 | 626 | iqr_factor(AskParams, State) -> 627 | Settings = State#state.settings, 628 | maps:get(iqr_factor, AskParams, Settings#settings.iqr_factor). 629 | 630 | accept(ActorId, Weight, Timestamp, State) 631 | when State#state.window_size >= (State#state.settings)#settings.max_window_size -> 632 | {value, Work} = queue:peek(State#state.window), 633 | UpdatedState = drop_work(Work, State), 634 | accept(ActorId, Weight, Timestamp, UpdatedState); 635 | accept(ActorId, Weight, Timestamp, State) -> 636 | Work = 637 | #work{ 638 | actor_id = ActorId, 639 | weight = Weight, 640 | timestamp = Timestamp 641 | }, 642 | 643 | UpdatedWindow = queue:in(Work, State#state.window), 644 | UpdatedWindowSize = State#state.window_size + 1, 645 | update_work_share(State#state.work_shares_table, ActorId, Weight), 646 | UpdatedState = 647 | State#state{ 648 | window = UpdatedWindow, 649 | window_size = UpdatedWindowSize 650 | }, 651 | set_work_stats_status(outdated, UpdatedState). 652 | 653 | update_work_share(Table, ActorId, ShareIncr) -> 654 | (ets:update_counter(Table, ActorId, {2,ShareIncr}, {ActorId,0}) =:= 0 655 | andalso ets:delete(Table, ActorId)). 656 | 657 | set_work_stats_status(Status, State) -> 658 | case {State#state.work_stats_status, Status} of 659 | {Same, Same} -> 660 | State; 661 | {updated, outdated} -> 662 | State#state{ work_stats_status = outdated }; 663 | {outdated, updating} -> 664 | State#state{ work_stats_status = updating }; 665 | {updating, updated} -> 666 | State#state{ work_stats_status = updated }; 667 | {updating, outdated} -> 668 | State 669 | end. 670 | 671 | %%------------------------------------------------------------------- 672 | %% Internal Functions Definitions - Collective Limiting 673 | %%------------------------------------------------------------------- 674 | 675 | initial_coll_limiter(Settings) -> 676 | Capacity = 677 | case Settings#settings.max_collective_rate of 678 | infinity -> infinity; 679 | MaxCollectiveRate -> (MaxCollectiveRate * ?COLL_LIMITER_UPDATE_PERIOD) div 1000 680 | end, 681 | #coll_limiter{ 682 | capacity = Capacity, 683 | accepted = 0, 684 | rejected = 0, 685 | desire_history = 0, 686 | desire_history_size = 0 687 | }. 688 | 689 | replenish_coll_limiter_capacity(LastReplenishTs, Settings, CollLimiter) -> 690 | Now = erlang:monotonic_time(), 691 | UpdatedCapacity = 692 | case Settings#settings.max_collective_rate =:= infinity of 693 | true -> 694 | infinity; 695 | _ -> 696 | % account for delays 697 | BaseRatio = ?COLL_LIMITER_UPDATE_PERIOD / 1000, 698 | TimeElapsed = Now - LastReplenishTs, 699 | DelayCompensationRatio = 700 | (TimeElapsed / 701 | erlang:convert_time_unit(?COLL_LIMITER_UPDATE_PERIOD, milli_seconds, native) 702 | ), 703 | trunc(BaseRatio * DelayCompensationRatio * Settings#settings.max_collective_rate) 704 | end, 705 | schedule_coll_limiter_capacity_replenishment(?COLL_LIMITER_UPDATE_PERIOD, Now), 706 | CollLimiter2 = CollLimiter#coll_limiter{ capacity = UpdatedCapacity }, 707 | CollLimiter3 = update_coll_limiter_desire_history(CollLimiter2), 708 | clear_coll_limiter_counters(CollLimiter3). 709 | 710 | schedule_coll_limiter_capacity_replenishment(Interval, LastReplenishTs) -> 711 | erlang:send_after(Interval, self(), {replenish_coll_limiter_capacity, LastReplenishTs}). 712 | 713 | update_coll_limiter_desire_history(CollLimiter) -> 714 | Accepted = CollLimiter#coll_limiter.accepted, 715 | Rejected = CollLimiter#coll_limiter.rejected, 716 | Desired = Accepted + Rejected, 717 | DesireHistory = CollLimiter#coll_limiter.desire_history, 718 | DesireHistorySize = CollLimiter#coll_limiter.desire_history_size, 719 | UpdatedDesireHistorySize = min(?COLL_LIMITER_MAX_DESIRE_HISTORY_SZ, DesireHistorySize + 1), 720 | UpdatedDesireHistory = 721 | case UpdatedDesireHistorySize =:= 1 of 722 | true -> 723 | Desired; 724 | _ -> 725 | WeightedPrev = (DesireHistory * (UpdatedDesireHistorySize - 1)) div UpdatedDesireHistorySize, 726 | WeightedNew = Desired div UpdatedDesireHistorySize, 727 | WeightedPrev + WeightedNew 728 | end, 729 | CollLimiter#coll_limiter{ desire_history = UpdatedDesireHistory, 730 | desire_history_size = UpdatedDesireHistorySize 731 | }. 732 | 733 | clear_coll_limiter_counters(CollLimiter) -> 734 | CollLimiter#coll_limiter{ accepted = 0, rejected = 0 }. 735 | 736 | check_collective_limit(_Weight, CollLimiter) 737 | when CollLimiter#coll_limiter.capacity =:= infinity -> 738 | yes; 739 | check_collective_limit(Weight, CollLimiter) 740 | when CollLimiter#coll_limiter.accepted + Weight > CollLimiter#coll_limiter.capacity -> 741 | no; 742 | check_collective_limit(_Weight, CollLimiter) 743 | when CollLimiter#coll_limiter.desire_history =< CollLimiter#coll_limiter.capacity -> 744 | yes; 745 | check_collective_limit(_Weight, CollLimiter) -> 746 | DieThrow = abs(erlang:monotonic_time()) rem CollLimiter#coll_limiter.desire_history, 747 | case DieThrow < CollLimiter#coll_limiter.capacity of 748 | true -> yes; 749 | _ -> no 750 | end. 751 | 752 | update_coll_limiter_acceptances(Weight, State) -> 753 | CollLimiter = State#state.coll_limiter, 754 | UpdatedCollLimiter = increment_tuple_field(#coll_limiter.accepted, CollLimiter, +Weight), 755 | State#state{ coll_limiter = UpdatedCollLimiter }. 756 | 757 | update_coll_limiter_rejections(Weight, State) -> 758 | CollLimiter = State#state.coll_limiter, 759 | UpdatedCollLimiter = increment_tuple_field(#coll_limiter.rejected, CollLimiter, +Weight), 760 | State#state{ coll_limiter = UpdatedCollLimiter }. 761 | 762 | increment_tuple_field(Index, Tuple, Incr) -> 763 | Value = element(Index, Tuple), 764 | setelement(Index, Tuple, Value + Incr). 765 | --------------------------------------------------------------------------------