├── 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 | %%
66 | %% - `Category' can be any term.
67 | %% - `Opts' must be a list of `aequitas_category:setting_opt()' values.
68 | %%
69 | %%
70 | %% Returns:
71 | %%
72 | %% - `ok' in case of success
73 | %% - `{error, Reason}' otherwise
74 | %%
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 | %%
97 | %% - `Category' must correspond to a started handler
98 | %%
99 | %%
100 | %% Returns:
101 | %%
102 | %% - `ok' in case of success
103 | %% - `{error, Reason}' otherwise
104 | %%
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 | %%
129 | %% - `Category' must refer to a started category handler.
130 | %% - `ActorId' can be any term.
131 | %% - `Opts' must be a list of `aequitas_category:ask_opt()' values
132 | %%
133 | %%
134 | %% Returns:
135 | %%
136 | %% - `accepted' if work execution was granted
137 | %% - `{rejected, Reason}' if work execution was denied
138 | %% - `{error, Reason}' if something went wrong
139 | %%
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 | %%
173 | %% - `{Tag, accepted}' if work execution as granted
174 | %% - `{Tag, {rejected, Reason}}' if work execution was denied
175 | %% - `{Tag, {accepted, Stats}}' if work execution as granted and stats requested
176 | %% - `{Tag, {{rejected, Reason}, Stats}}' if work execution was denied and stats requested
177 | %% - `{''`DOWN''`, Monitor, process, _Pid, _Reason}' if the handler stopped
178 | %%
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 | %%
199 | %% - `Category' must refer to a started category handler.
200 | %% - `SettingOpts' must be a list of `aequitas_category:setting_opt()' values
201 | %%
202 | %%
203 | %% Returns:
204 | %%
205 | %% - `ok' in case of success
206 | %% - `{error, Reason}' otherwise
207 | %%
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://hex.pm/packages/aequitas)
7 | [](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 |
47 | - `:ask(Category, ActorId, Opts)'
48 | - for tweaking
49 |
50 | - `:async_ask(Category, ActorId)'
51 | - analogous to `:ask/2' but replies asynchronously
52 |
53 | - `:async_ask(Category, ActorId, Opts)'
54 | - analogous to `:ask/3' but replies asynchronously
55 |
56 |
57 |
58 | Documentation and Reference
59 |
60 | Documentation and reference are hosted on HexDocs.
61 |
62 | Tested setup
63 |
64 |
65 | - Erlang/OTP 22 or higher
66 | - rebar3
67 |
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 |
76 | - `{max_window_size, _}'
77 |
78 | - Enforces a ceiling on how many of the last work acceptances
79 | will be tracked
80 |
81 | - Default is 10000; value must be a positive integer or infinity
82 |
83 |
84 | - `{max_window_duration, _}'
85 |
86 | - Enforces an eventual expiration of tracked work acceptances
87 | - Default is `{seconds,5}'; value must be of type `aequitas_time_interval:t()' or infinity
88 | (see type reference)
89 |
90 |
91 |
92 | - `{min_actor_count, _}'
93 |
94 | - Establishes the requirement of a minimum amount of tracked actors
95 | before outlier detection is performed
96 |
97 | - Default is 30; value must be a positive integer
98 |
99 |
100 | - `{iqr_factor, _}'
101 |
102 | - IQR factor used to detect outlying actors among the workload distribution
103 | - Default is 1.5; value must be a non-negative number
104 |
105 |
106 | - `{max_collective_rate, _}'
107 |
108 | - Enforces a collective rate limit,
109 | per second, on work acceptances
110 |
111 | - Default is infinity; value must be a non-negative integer
112 |
113 |
114 |
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 |
122 | - `{weight, _}'
123 |
124 | - Relative weight of the work request
125 | - Default is 1; value must be a positive integer
126 |
127 |
128 | - `{min_actor_count, _}'
129 |
130 | - Overrides the `min_actor_count' configured globally for the category;
131 | its meaning remains but it only applies to the work request that overrode it
132 |
133 |
134 |
135 | - `{iqr_factor, _}'
136 |
137 | - Overrides the `iqr_factor' configured globally for the category;
138 | its meaning remains but it only applies to the work request that overrode it
139 |
140 |
141 |
142 | - `return_stats'
143 |
144 | - Return the work stats used to detect outliers together with acceptance status
145 | (see API reference)
146 |
147 |
148 |
149 |
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 |
--------------------------------------------------------------------------------