-
Notifications
You must be signed in to change notification settings - Fork 14
rate limiter in logger, opt-in #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
436e677
9f3e096
a27529a
d6991e0
0e8f7cb
0e5b416
fe5f45e
177c6f1
663c728
9341ce0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,3 +25,56 @@ let with_output_bin name k = with_open_out_bin name (fun ch -> bracket (IO.outpu | |
| let with_output_txt name k = with_open_out_txt name (fun ch -> bracket (IO.output_channel ch) IO.flush k) | ||
|
|
||
| let with_opendir dir = bracket (Unix.opendir dir) Unix.closedir | ||
|
|
||
| (* token bucket | ||
| https://en.wikipedia.org/wiki/Token_bucket *) | ||
| module Rate_limit = struct | ||
| type t = | ||
| | Unlimited | ||
| | RL of { | ||
| mutable tokens: float; | ||
| mutable count_silenced: int; | ||
| mutable last_update: float; | ||
| capacity: float; | ||
| rate: float; (** new tokens/sec *) | ||
| } | ||
|
|
||
| let unlimited = Unlimited | ||
|
|
||
| let create ?(burst_factor=5) ~allowed_per_sec () : t = | ||
| if classify_float allowed_per_sec <> FP_normal || allowed_per_sec <= 0. then | ||
| invalid_arg "Rate_limit.create: allowed_per_sec must be finite and positive"; | ||
|
|
||
| if burst_factor < 1 then invalid_arg "Rate_limit.create: burst capacity must be >= 1"; | ||
| let capacity = max 1. (float burst_factor *. allowed_per_sec) in | ||
| RL { | ||
| tokens=capacity; last_update=Time.now(); count_silenced=0; capacity; | ||
| rate=allowed_per_sec; | ||
| } | ||
|
|
||
| let take_rate_limited_count = function | ||
| | Unlimited -> 0 | ||
| | RL rl -> | ||
| let n = rl.count_silenced in | ||
| rl.count_silenced <- 0; | ||
| n | ||
|
|
||
| let attempt = function | ||
| | Unlimited -> true | ||
| | RL rl -> | ||
| let now = Time.now() in | ||
|
|
||
| if now > rl.last_update then ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is pretty much guaranteed to be always true unless it is called in a tight loop with nothing else to do (but then why rate limit noop action), idk if it is important.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. well sure, although this is using we could use mtime if that's better?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i would use integer division and refill only full tokens (and update timestamp when refill actually happens)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like it'd still lead to token loss. Say we refill at 1.5 token/second, with an empty bucket, and emit one log every second. We should refill 1.5 token (time delta is 1.) but truncate to 1, so the log is accepted but the bucket remains empty. A burst of 2 log messages at once thus leads to a rejection of the second message, even though we emit logs at 1 log/second. edit: looks like Go's stdlib uses float64 for the counter |
||
| rl.tokens <- min rl.capacity | ||
| (rl.tokens +. rl.rate *. (now -. rl.last_update)); | ||
| rl.last_update <- now; | ||
| ); | ||
|
|
||
| if rl.tokens >= 1. then ( | ||
| rl.tokens <- rl.tokens -. 1.; | ||
| true | ||
| ) else ( | ||
| rl.count_silenced <- 1 + rl.count_silenced; | ||
| false | ||
| ) | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| open Devkit | ||
|
|
||
| let fail expected actual = | ||
| Printf.eprintf "expected:\n%s\nactual:\n%s\n" expected actual; | ||
| exit 1 | ||
|
|
||
| let expect_invalid_rate rate = | ||
| match Control.Rate_limit.create ~allowed_per_sec:rate () with | ||
| | exception Invalid_argument _ -> () | ||
| | _ -> fail "Invalid_argument" "rate limiter created" | ||
|
|
||
| let expect_invalid_capacity burst_factor = | ||
| match Control.Rate_limit.create ~burst_factor ~allowed_per_sec:1. () with | ||
| | exception Invalid_argument _ -> () | ||
| | _ -> fail "Invalid_argument" "rate limiter created" | ||
|
|
||
| let logging_lines count = | ||
| let rec loop i acc = | ||
| if i < 0 then acc else loop (i - 1) (Printf.sprintf "logging %d" i :: acc) | ||
| in | ||
| loop (count - 1) [] | ||
|
|
||
| let () = | ||
| List.iter expect_invalid_rate [0.; -1.; infinity; nan]; | ||
| List.iter expect_invalid_capacity [0; -1]; | ||
|
|
||
| (* A very low rate must still have capacity for its initial token. *) | ||
| let slow = Control.Rate_limit.create ~allowed_per_sec:0.01 () in | ||
| if not (Control.Rate_limit.attempt slow) then fail "allowed" "rate limited"; | ||
| if Control.Rate_limit.attempt slow then fail "rate limited" "allowed"; | ||
| if Control.Rate_limit.take_rate_limited_count slow <> 1 then | ||
| fail "one rate-limited attempt" "unexpected count"; | ||
| if Control.Rate_limit.take_rate_limited_count slow <> 0 then | ||
| fail "reset rate-limited count" "non-zero count"; | ||
|
|
||
| let output = Buffer.create 256 in | ||
| let target = { Logger. | ||
| format = (fun _level _facility _timestamp _pairs message -> message); | ||
| output = (fun _level _facility message -> | ||
| Buffer.add_string output message; | ||
| Buffer.add_char output '\n'); | ||
| } in | ||
| let logger = Logger.put_simple target in | ||
| let log = new Log.logger ~logger (Log.facility "rate-limit-test") in | ||
| let rate_limit = Control.Rate_limit.create ~burst_factor:7 ~allowed_per_sec:2. () in | ||
| let emit count = | ||
| for i = 0 to count - 1 do | ||
| log#info ~rate_limit "logging %d" i | ||
| done | ||
| in | ||
| emit 10_000; | ||
| Unix.sleep 2; | ||
| (* Emit only the number guaranteed to have been refilled. This keeps a | ||
| delayed test process from changing the expected output. *) | ||
| emit 4; | ||
| let expected = | ||
| String.concat "\n" | ||
| (logging_lines 14 @ | ||
| ["(9986 messages have been rate limited)"] @ | ||
| logging_lines 4 @ [""]) | ||
| in | ||
| let actual = Buffer.contents output in | ||
| if actual <> expected then fail expected actual |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tokens as float? i guess this helps avoid thinking about rounding but still feels strange
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't really know another way to deal with fractional refilling (what if we refill 3.4 tokens?). Well an alternative is to round up or down depending on
Random.float (refill -. floor refill)(ierandom(0.4)forrefill=3.4) but that seems even weirder.