Skip to content

Conversation

@cplaursen
Copy link
Contributor

Proof of concept for XAPI throttling.
This allows users to specify the user-agent rate limited clients in xapi.conf, which then consume from a token bucket whenever a request is made, and have to wait for it to refill if they exceed its capacity.

The token bucket library implements the token bucket algorithm, to
be used for rate-limiting.

This commit implements basic token buckets, which contain tokens that
are refilled over time according to their refill parameter, up to a
maximum determined by the burst parameter.

Tokens can be consumed in a thread-safe way - consuming returns false
when there are not enough tokens available, and true when the operation
was successful.

Signed-off-by: Christian Pardillo Laursen <christian.pardillolaursen@citrix.com>
Signed-off-by: Christian Pardillo Laursen <christian.pardillolaursen@citrix.com>
Bucket tables map client identifiers to their token buckets, and are
the main data structure for rate limiting.

Signed-off-by: Christian Pardillo Laursen <christian.pardillolaursen@citrix.com>
To be replaced with a proper datamodel. Bucket tables are used for
mapping requests to their respective token bucket so that they can
be rate limited.

Signed-off-by: Christian Pardillo Laursen <christian.pardillolaursen@citrix.com>
Signed-off-by: Christian Pardillo Laursen <christian.pardillolaursen@citrix.com>
Signed-off-by: Christian Pardillo Laursen <christian.pardillolaursen@citrix.com>
Signed-off-by: Christian Pardillo Laursen <christian.pardillolaursen@citrix.com>
@cplaursen cplaursen changed the title Token bucket XAPI throttling proof of concept Dec 1, 2025
@cplaursen cplaursen requested a review from robhoes December 1, 2025 13:39
Copy link
Member

@psafont psafont left a comment

Choose a reason for hiding this comment

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

It's a bit tough to understand how the datastructure does rate-limitting. I had to find documentation online to undestand it. As such, I would appreciate a more in-depth explanation at the header of the mli file. For example, it would be good to understand when is the "refill" timestamp changed, and explain that some of the methods are only meant to be used for testing.

(** Create token bucket with given parameters and supplied inital timestamp
@param timestamp Initial timestamp
@param burst_size Maximum number of tokens that can fit in the bucket
@param fill_rate Number of tokens added to the bucket per second
Copy link
Member

Choose a reason for hiding this comment

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

fill_rate has an implicit unit (Hz), I think that it can be worth representing the fill rate as the amount of time it takes the bucket from being empty to becoming full. This is a timespan, and we can use a known datatype with explicit unit here: Mtime.span.

How some operations would be changed:

let peek_with_delta time_delta tb =
  let fill_time = Mtime.Span.to_float_ns tb.fill_time in
  let time_delta = Mtime.Span.to_float_ns time_delta in
  min tb.burst_size (tb.tokens +. (time_delta /. fill_time)

let delay_until_available_with_delta delta tb amount =
  let current_tokens = peek_with_delta delta tb in
  let required_tokens = max 0. (amount -. current_tokens) in
  required_tokens *. (Mtime.Span.to_float_ns tb.fill_time)
  |> Float.to_int64
  |> Mtime.Span.of_unit64_ns 

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can see the appeal of adding units to the fill_rate but I'd rather keep the burst size decoupled from fill rate, and I think it's more intuitive as tokens per second than seconds per token.

burst_size: float
; fill_rate: float
; mutable tokens: float
; mutable last_refill: Mtime.span
Copy link
Member

Choose a reason for hiding this comment

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

Is there any reason this cannot be a counter? They return the time difference directly: https://ocaml.org/p/mtime/latest/doc/mtime.clock/Mtime_clock/index.html#counters

This would mean that the peek functions would turn into:

let peek_with_delta time_delta tb =
  let time_delta_seconds = Mtime.Span.to_float_ns time_delta *. 1e-9 in
  min tb.burst_size (tb.tokens +. (time_delta_seconds *. tb.fill_rate))

let peek tb = peek_with_delta (Mtime_clock.count tb.last_refill) tb

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Keeping a counter does simplify things a bit, but if I understand correctly it forces us to make two system time calls when consuming tokens - one to obtain the difference from the counter, and another to produce a new counter. It probably won't make much of a difference, I can try profiling.

Signed-off-by: Christian Pardillo Laursen <christian.pardillolaursen@citrix.com>
@cplaursen
Copy link
Contributor Author

Added some documentation to the token bucket module to explain the rate limit application.

Zero or negative rate limits can cause issues in the behaviour of rate
limiting. In particular, zero fill rate leads to a division by zero in time
calculations. Rather than account for this, we forbid the creation of token
buckets with a bad fill rate by returning None.

Signed-off-by: Christian Pardillo Laursen <christian.pardillolaursen@citrix.com>
@cplaursen cplaursen force-pushed the token-bucket branch 3 times, most recently from b9098c1 to dd10c1c Compare December 2, 2025 10:38
Signed-off-by: Christian Pardillo Laursen <christian.pardillolaursen@citrix.com>
Make token bucket type abstract to hide Hashtbl.t
Use `replace` rather than `add` for adding a new bucket

Signed-off-by: Christian Pardillo Laursen <christian.pardillolaursen@citrix.com>
* GNU Lesser General Public License for more details.
*)

type t = (string, Token_bucket.t) Hashtbl.t
Copy link
Contributor

Choose a reason for hiding this comment

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

I suggest adding a mutex here, so that we can use this from XAPI's API server (unless we're really sure only 1 thread would use this at a time).

Copy link
Contributor

Choose a reason for hiding this comment

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

If this is only set up once on startup and never changed then we'd like need a kind of immutable hashtable, perhaps a bool to say that from now on modifications to the hashtbl are not allowed

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The plan is to allow changes via the CLI, but we should probably protect this just in case.

()
else
let wait_time = Token_bucket.delay_until_available bucket amount in
Thread.delay wait_time ; try_consume ()
Copy link
Contributor

Choose a reason for hiding this comment

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

But if we add a mutex we'll need to release it here. Perhaps as a start use the mutex just to protect the data structure?

Also if the hashtbl changes rarely we could also use a Map in an Atomic.t

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants