Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions control.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

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

Copy link
Copy Markdown
Contributor Author

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) (ie random(0.4) for refill=3.4) but that seems even weirder.

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 (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well sure, although this is using Unix.gettimeofday() so the clock could in theory step backwards.

we could use mtime if that's better?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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)

@c-cube c-cube Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
19 changes: 19 additions & 0 deletions control.mli
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,22 @@ val with_output_txt : string -> (unit IO.output -> 'a) -> 'a
(** Misc. *)

val with_opendir : string -> (Unix.dir_handle -> 'b) -> 'b


module Rate_limit : sig
type t
val unlimited : t
val create : ?burst_factor:int -> allowed_per_sec:float -> unit -> t
(** Create a token-bucket rate limiter. The bucket starts full.
@param burst_factor limits the maximum size of a burst of activity
as a factor of the base rate limit.
@param allowed_per_sec sustained number of operations allowed per second,
ie asymptotic maximum rate.
@raise Invalid_argument if [allowed_per_sec] is not finite and positive. *)

val take_rate_limited_count: t -> int
(** How many attempts have been rate limited since last time this was called? *)

val attempt : t -> bool
(** Attempt to perform one action. Return [true] if allowed by rate limiter. *)
end
8 changes: 7 additions & 1 deletion dune
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
memory_jemalloc
test
test_gzip
test_httpev)
test_httpev
test_log_rate_limit)
(preprocess
(per_module
((pps lwt_ppx)
Expand Down Expand Up @@ -76,6 +77,11 @@
(libraries devkit extlib)
(modules test_gzip))

(test
(name test_log_rate_limit)
(libraries devkit unix)
(modules test_log_rate_limit))

(rule
(alias runtest)
(action (run ./test.exe)))
Expand Down
38 changes: 21 additions & 17 deletions log.ml
Original file line number Diff line number Diff line change
Expand Up @@ -184,26 +184,30 @@ let read_env_config = State.read_env_config

param [structured_pairs] key/value pairs to use for structured log formats only. Plain logging will discard.
*)
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
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

class logger facil =
let make_s (output_line:Logger.facil -> Time.t -> Logger.Pairs.t -> string -> unit) =
class logger ?(logger=State.logger) facil =
Comment thread
c-cube marked this conversation as resolved.
let make_s (logger: Logger.t) (level:Logger.level) =
let output = function
| true ->
fun facil ts pairs s ->
if String.contains s '\n' then
List.iter (output_line facil ts pairs) @@ String.nsplit s "\n"
List.iter (logger.put level facil ts pairs) @@ String.nsplit s "\n"
else
output_line facil ts pairs s
| false -> output_line
logger.put level facil ts pairs s
| false -> logger.put level
in
let print_bt lines exn bt ts pairs s =
output lines facil ts pairs (s ^ " : exn " ^ Exn.str exn ^ (if bt = [] then " (no backtrace)" else ""));
List.iter (fun line -> output_line facil ts pairs (" " ^ line)) bt
List.iter (fun line -> logger.put level facil ts pairs (" " ^ line)) bt
in
fun ?exn ?(lines=true) ?(backtrace=false) ?saved_backtrace ?(ts=Unix.gettimeofday()) ?(structured_pairs=[]) ?(pairs=[]) s ->
fun ?(rate_limit=Control.Rate_limit.unlimited) ?exn ?(lines=true) ?(backtrace=false) ?saved_backtrace ?(ts=Unix.gettimeofday()) ?(structured_pairs=[]) ?(pairs=[]) s ->
if logger.allowed facil level && Control.Rate_limit.attempt rate_limit then
let pairs = if State.is_structured_format () then List.rev_append structured_pairs pairs else pairs in
try
if Logger.allowed facil `Warn then (
let rate_limited = Control.Rate_limit.take_rate_limited_count rate_limit in
if rate_limited > 0 then logger.put `Warn facil ts [] (sprintf "(%d messages have been rate limited)" rate_limited));
match exn with
| None -> output lines facil ts pairs s
| Some exn ->
Expand All @@ -214,17 +218,17 @@ class logger facil =
| true -> print_bt lines exn (Exn.get_backtrace ()) ts pairs s
| false -> output lines facil ts pairs (s ^ " : exn " ^ Exn.str exn)
with exn ->
output_line facil ts pairs (sprintf "LOG FAILED : %S with message %S" (Exn.str exn) s)
logger.put level facil ts pairs (sprintf "LOG FAILED : %S with message %S" (Exn.str exn) s)
in
let make : _ -> _ pr = fun output ?exn ?lines ?backtrace ?saved_backtrace ?ts ?structured_pairs ?pairs fmt ->
ksprintf (fun s -> output ?exn ?lines ?backtrace ?saved_backtrace ?ts ?structured_pairs ?pairs s) fmt
let make : _ -> _ pr = fun output ?rate_limit ?exn ?lines ?backtrace ?saved_backtrace ?ts ?structured_pairs ?pairs fmt ->
ksprintf (fun s -> output ?rate_limit ?exn ?lines ?backtrace ?saved_backtrace ?ts ?structured_pairs ?pairs s) fmt
in
let debug_s = make_s (State.logger.put `Debug) in
let warn_s = make_s (State.logger.put `Warn) in
let info_s = make_s (State.logger.put `Info) in
let error_s = make_s (State.logger.put `Error) in
let critical_s = make_s (State.logger.put `Critical) in
let put_s level = make_s (State.logger.put level) in
let debug_s = make_s logger `Debug in
let warn_s = make_s logger `Warn in
let info_s = make_s logger `Info in
let error_s = make_s logger `Error in
let critical_s = make_s logger `Critical in
let put_s level = make_s logger level in
object
method debug_s = debug_s
method warn_s = warn_s
Expand Down
6 changes: 4 additions & 2 deletions logger.ml
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ type target = {

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

let put_simple (t:target) : t = {
allowed;
put = fun level facil ts pairs str ->
if allowed facil level then
t.output level facil (t.format level facil ts pairs str)
Expand Down
63 changes: 63 additions & 0 deletions test_log_rate_limit.ml
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
Loading