Skip to content

Commit 4aa7e7c

Browse files
authored
logger: rate limit (#62)
1 parent d1e80c7 commit 4aa7e7c

6 files changed

Lines changed: 170 additions & 20 deletions

File tree

control.ml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,56 @@ let with_output_bin name k = with_open_out_bin name (fun ch -> bracket (IO.outpu
2525
let with_output_txt name k = with_open_out_txt name (fun ch -> bracket (IO.output_channel ch) IO.flush k)
2626

2727
let with_opendir dir = bracket (Unix.opendir dir) Unix.closedir
28+
29+
(* token bucket
30+
https://en.wikipedia.org/wiki/Token_bucket *)
31+
module Rate_limit = struct
32+
type t =
33+
| Unlimited
34+
| RL of {
35+
mutable tokens: float;
36+
mutable count_silenced: int;
37+
mutable last_update: float;
38+
capacity: float;
39+
rate: float; (** new tokens/sec *)
40+
}
41+
42+
let unlimited = Unlimited
43+
44+
let create ?(burst_factor=5) ~allowed_per_sec () : t =
45+
if classify_float allowed_per_sec <> FP_normal || allowed_per_sec <= 0. then
46+
invalid_arg "Rate_limit.create: allowed_per_sec must be finite and positive";
47+
48+
if burst_factor < 1 then invalid_arg "Rate_limit.create: burst factor must be >= 1";
49+
let capacity = max 1. (float burst_factor *. allowed_per_sec) in
50+
RL {
51+
tokens=capacity; last_update=Time.now(); count_silenced=0; capacity;
52+
rate=allowed_per_sec;
53+
}
54+
55+
let take_rate_limited_count = function
56+
| Unlimited -> 0
57+
| RL rl ->
58+
let n = rl.count_silenced in
59+
rl.count_silenced <- 0;
60+
n
61+
62+
let attempt = function
63+
| Unlimited -> true
64+
| RL rl ->
65+
let now = Time.now() in
66+
67+
if now > rl.last_update then (
68+
rl.tokens <- min rl.capacity
69+
(rl.tokens +. rl.rate *. (now -. rl.last_update));
70+
rl.last_update <- now;
71+
);
72+
73+
if rl.tokens >= 1. then (
74+
rl.tokens <- rl.tokens -. 1.;
75+
true
76+
) else (
77+
rl.count_silenced <- 1 + rl.count_silenced;
78+
false
79+
)
80+
end

control.mli

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,22 @@ val with_output_txt : string -> (unit IO.output -> 'a) -> 'a
4343
(** Misc. *)
4444

4545
val with_opendir : string -> (Unix.dir_handle -> 'b) -> 'b
46+
47+
48+
module Rate_limit : sig
49+
type t
50+
val unlimited : t
51+
val create : ?burst_factor:int -> allowed_per_sec:float -> unit -> t
52+
(** Create a token-bucket rate limiter. The bucket starts full.
53+
@param burst_factor limits the maximum size of a burst of activity
54+
as a factor of the base rate limit.
55+
@param allowed_per_sec sustained number of operations allowed per second,
56+
ie asymptotic maximum rate.
57+
@raise Invalid_argument if [allowed_per_sec] is not finite and positive. *)
58+
59+
val take_rate_limited_count: t -> int
60+
(** How many attempts have been rate limited since last time this was called? *)
61+
62+
val attempt : t -> bool
63+
(** Attempt to perform one action. Return [true] if allowed by rate limiter. *)
64+
end

dune

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
memory_jemalloc
3737
test
3838
test_gzip
39-
test_httpev)
39+
test_httpev
40+
test_log_rate_limit)
4041
(preprocess
4142
(per_module
4243
((pps lwt_ppx)
@@ -76,6 +77,11 @@
7677
(libraries devkit extlib)
7778
(modules test_gzip))
7879

80+
(test
81+
(name test_log_rate_limit)
82+
(libraries devkit unix)
83+
(modules test_log_rate_limit))
84+
7985
(rule
8086
(alias runtest)
8187
(action (run ./test.exe)))

log.ml

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -184,26 +184,33 @@ let read_env_config = State.read_env_config
184184
185185
param [structured_pairs] key/value pairs to use for structured log formats only. Plain logging will discard.
186186
*)
187-
type 'a pr = ?exn:exn -> ?lines:bool -> ?backtrace:bool -> ?saved_backtrace:string list -> ?ts:Time.t -> ?structured_pairs:Logger.Pairs.t -> ?pairs:Logger.Pairs.t -> ('a, unit, string, unit) format4 -> 'a
187+
type 'a pr = ?rate_limit:Control.Rate_limit.t -> ?exn:exn -> ?lines:bool -> ?backtrace:bool -> ?saved_backtrace:string list -> ?ts:Time.t -> ?structured_pairs:Logger.Pairs.t -> ?pairs:Logger.Pairs.t -> ('a, unit, string, unit) format4 -> 'a
188188

189-
class logger facil =
190-
let make_s (output_line:Logger.facil -> Time.t -> Logger.Pairs.t -> string -> unit) =
189+
(** Default rate limiter, shared between all the loggers *)
190+
let main_rate_limiter = Control.Rate_limit.create ~burst_factor:10 ~allowed_per_sec:1_000. ()
191+
192+
class logger ?(logger=State.logger) facil =
193+
let make_s (logger: Logger.t) (level:Logger.level) =
191194
let output = function
192195
| true ->
193196
fun facil ts pairs s ->
194197
if String.contains s '\n' then
195-
List.iter (output_line facil ts pairs) @@ String.nsplit s "\n"
198+
List.iter (logger.put level facil ts pairs) @@ String.nsplit s "\n"
196199
else
197-
output_line facil ts pairs s
198-
| false -> output_line
200+
logger.put level facil ts pairs s
201+
| false -> logger.put level
199202
in
200203
let print_bt lines exn bt ts pairs s =
201204
output lines facil ts pairs (s ^ " : exn " ^ Exn.str exn ^ (if bt = [] then " (no backtrace)" else ""));
202-
List.iter (fun line -> output_line facil ts pairs (" " ^ line)) bt
205+
List.iter (fun line -> logger.put level facil ts pairs (" " ^ line)) bt
203206
in
204-
fun ?exn ?(lines=true) ?(backtrace=false) ?saved_backtrace ?(ts=Unix.gettimeofday()) ?(structured_pairs=[]) ?(pairs=[]) s ->
207+
fun ?(rate_limit=main_rate_limiter) ?exn ?(lines=true) ?(backtrace=false) ?saved_backtrace ?(ts=Unix.gettimeofday()) ?(structured_pairs=[]) ?(pairs=[]) s ->
208+
if logger.allowed facil level && Control.Rate_limit.attempt rate_limit then
205209
let pairs = if State.is_structured_format () then List.rev_append structured_pairs pairs else pairs in
206210
try
211+
if Logger.allowed facil `Warn then (
212+
let rate_limited = Control.Rate_limit.take_rate_limited_count rate_limit in
213+
if rate_limited > 0 then logger.put `Warn facil ts [] (sprintf "(%d messages have been rate limited)" rate_limited));
207214
match exn with
208215
| None -> output lines facil ts pairs s
209216
| Some exn ->
@@ -214,17 +221,17 @@ class logger facil =
214221
| true -> print_bt lines exn (Exn.get_backtrace ()) ts pairs s
215222
| false -> output lines facil ts pairs (s ^ " : exn " ^ Exn.str exn)
216223
with exn ->
217-
output_line facil ts pairs (sprintf "LOG FAILED : %S with message %S" (Exn.str exn) s)
224+
logger.put level facil ts pairs (sprintf "LOG FAILED : %S with message %S" (Exn.str exn) s)
218225
in
219-
let make : _ -> _ pr = fun output ?exn ?lines ?backtrace ?saved_backtrace ?ts ?structured_pairs ?pairs fmt ->
220-
ksprintf (fun s -> output ?exn ?lines ?backtrace ?saved_backtrace ?ts ?structured_pairs ?pairs s) fmt
226+
let make : _ -> _ pr = fun output ?rate_limit ?exn ?lines ?backtrace ?saved_backtrace ?ts ?structured_pairs ?pairs fmt ->
227+
ksprintf (fun s -> output ?rate_limit ?exn ?lines ?backtrace ?saved_backtrace ?ts ?structured_pairs ?pairs s) fmt
221228
in
222-
let debug_s = make_s (State.logger.put `Debug) in
223-
let warn_s = make_s (State.logger.put `Warn) in
224-
let info_s = make_s (State.logger.put `Info) in
225-
let error_s = make_s (State.logger.put `Error) in
226-
let critical_s = make_s (State.logger.put `Critical) in
227-
let put_s level = make_s (State.logger.put level) in
229+
let debug_s = make_s logger `Debug in
230+
let warn_s = make_s logger `Warn in
231+
let info_s = make_s logger `Info in
232+
let error_s = make_s logger `Error in
233+
let critical_s = make_s logger `Critical in
234+
let put_s level = make_s logger level in
228235
object
229236
method debug_s = debug_s
230237
method warn_s = warn_s

logger.ml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,12 @@ type target = {
4747

4848
(** A logger *)
4949
type t = {
50-
put : level -> facil -> Time.t -> Pairs.t -> string -> unit
51-
} [@@unboxed]
50+
put : level -> facil -> Time.t -> Pairs.t -> string -> unit;
51+
allowed : facil -> level -> bool;
52+
}
5253

5354
let put_simple (t:target) : t = {
55+
allowed;
5456
put = fun level facil ts pairs str ->
5557
if allowed facil level then
5658
t.output level facil (t.format level facil ts pairs str)

test_log_rate_limit.ml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
open Devkit
2+
3+
let fail expected actual =
4+
Printf.eprintf "expected:\n%s\nactual:\n%s\n" expected actual;
5+
exit 1
6+
7+
let expect_invalid_rate rate =
8+
match Control.Rate_limit.create ~allowed_per_sec:rate () with
9+
| exception Invalid_argument _ -> ()
10+
| _ -> fail "Invalid_argument" "rate limiter created"
11+
12+
let expect_invalid_capacity burst_factor =
13+
match Control.Rate_limit.create ~burst_factor ~allowed_per_sec:1. () with
14+
| exception Invalid_argument _ -> ()
15+
| _ -> fail "Invalid_argument" "rate limiter created"
16+
17+
let logging_lines count =
18+
let rec loop i acc =
19+
if i < 0 then acc else loop (i - 1) (Printf.sprintf "logging %d" i :: acc)
20+
in
21+
loop (count - 1) []
22+
23+
let () =
24+
List.iter expect_invalid_rate [0.; -1.; infinity; nan];
25+
List.iter expect_invalid_capacity [0; -1];
26+
27+
(* A very low rate must still have capacity for its initial token. *)
28+
let slow = Control.Rate_limit.create ~allowed_per_sec:0.01 () in
29+
if not (Control.Rate_limit.attempt slow) then fail "allowed" "rate limited";
30+
if Control.Rate_limit.attempt slow then fail "rate limited" "allowed";
31+
if Control.Rate_limit.take_rate_limited_count slow <> 1 then
32+
fail "one rate-limited attempt" "unexpected count";
33+
if Control.Rate_limit.take_rate_limited_count slow <> 0 then
34+
fail "reset rate-limited count" "non-zero count";
35+
36+
let output = Buffer.create 256 in
37+
let target = { Logger.
38+
format = (fun _level _facility _timestamp _pairs message -> message);
39+
output = (fun _level _facility message ->
40+
Buffer.add_string output message;
41+
Buffer.add_char output '\n');
42+
} in
43+
let logger = Logger.put_simple target in
44+
let log = new Log.logger ~logger (Log.facility "rate-limit-test") in
45+
let rate_limit = Control.Rate_limit.create ~burst_factor:7 ~allowed_per_sec:2. () in
46+
let emit count =
47+
for i = 0 to count - 1 do
48+
log#info ~rate_limit "logging %d" i
49+
done
50+
in
51+
emit 10_000;
52+
Unix.sleep 2;
53+
(* Emit only the number guaranteed to have been refilled. This keeps a
54+
delayed test process from changing the expected output. *)
55+
emit 4;
56+
let expected =
57+
String.concat "\n"
58+
(logging_lines 14 @
59+
["(9986 messages have been rate limited)"] @
60+
logging_lines 4 @ [""])
61+
in
62+
let actual = Buffer.contents output in
63+
if actual <> expected then fail expected actual

0 commit comments

Comments
 (0)