4

Suppose I have an HTTP client to call a server with a request rate limit, e.g. 1000 requests/ sec. I implemented a rate limiter in ExecutionContext like this:

Created a bounded blocking queue with RateLimiter of Guava

class MyBlockingQueue[A](capacity: Int, permitsPerSecond: Int) 
  extends ArrayBlockingQueue[A](capacity) {

  private val rateLimiter = RateLimiter.create(permitsPerSecond.toDouble)

  override def take(): A = {
    rateLimiter.acquire()
    super.take()
  }

  override def poll(timeout: Long, unit: TimeUnit): A = {
    rateLimiter.tryAcquire(timeout, unit) // todo: fix it
    super.poll(timeout, unit)
  }
}

Created an ExecutionContext from a ThreadPoolExecutor with this queue.

def createRateLimitingExecutionContext(numThreads: Int,
                                       capacity: Int,
                                       permitsPerSecond: Int): ExecutionContext = {
  val queue = new MyBlockingQueue[Runnable](capacity, permitsPerSecond)
  val executor = new ThreadPoolExecutor(numThreads, numThreads, 0L, TimeUnit.MILLISECONDS, queue)
  ExecutionContext.fromExecutor(executor)
}

Now I can create an ExecutionContext with a rate limit and pass it to the client:

implicit val ec = createRateLimitingThreadPoolExecutionContext(
  numThreads = 100,
  capacity = 1000,
  permitsPerSecond = 1000
)
httpGet("http://myserver.com/xyz") // create Futures with "ec"  

Does it make sense ? How would you test this ExecutionContext ?

Michael
  • 37,415
  • 63
  • 167
  • 303

2 Answers2

3

It seems kinda ok, except the custom execution context should be explicit and/or managed inside the httpGet or its enclosing class, not a global implicit.

Because otherwise, when you write something like this for example:

    httpGet(foo)
     .recover("")
     .map(_.split(","))
     .map(_.map(_.toInt))
     .map(_.max)
     .foreach(println)

You end up consuming 6(!) permits, not one - i.e., it counts as if you have made 6 requests, which is probably not what you want.

Dima
  • 33,157
  • 5
  • 34
  • 54
  • Thanks for noting that my execution context should be explicit and managed by the `httpGet` component. It's very important. – Michael May 12 '21 at 13:51
1

For testing this custom ExecutionContext, you should be able to create a test with a behavior similar to the following:

  • schedule firing a bunch of Futures for few seconds
  • each Future modify an atomic value (like a counter) on which you'll be able to assert later
  • regularly check the value of the atomic value: assuming you allow 10 futures/second, then checking each second you should your counter less than or equal to previous value + 10
// Pseudo-code

implicit val ec: ExecutionContext = ??? // your custom ExecutionContext allowing only 10 futures/second

val counter = new AtomicInteger()

// Fire some Futures
val start = Instant.now
val futures = (1 to 100).map(_ => Future { counter.getAndIncrement() }) )

// Check every second
(0 to 10).foreach { i =>
  counter.get() shouldBe between (i-1)*10 and (i+1)*10
  Thread.sleep(1000)
}

// Final check
Await.result(Future.sequence(futures))
val end = Instant.now
(end - start) shouldBe > 10s

This is just a rough basic idea, you can adapt for different scenarios.

Maybe a counter is too basic and you'll want more fine-grained assertions. Also here the Futures complete almost instantly, you can also simulate operations lasting longer.

Keep in mind that, as always with timing sensitive operations, you'll probably not be able to assert on specific values but assert on some value with an acceptable error margin.

Finally, you can also rely on the Guava RateLimiter to have been tested extensively. Thus you might want to consider it as a boundary of your test and only test the different interactions with it but not all the possible timing scenarios.

Gaël J
  • 2,589
  • 1
  • 13
  • 21
  • Thanks for the idea of using futures and an atomic counter. I will try it. – Michael May 12 '21 at 13:52
  • 1
    I agree that `RateLimiter` has been tested extensively. However it's just an implementation details and can be replaced. I need a good test to make sure the rate limit works as expected. So I will be able to change the implementation easily. – Michael May 12 '21 at 13:56
  • @Michael mock the rate limiter to fail `acquire`, test that request doesn't go through. The mock it to succeed, and test that it does. Shouldn't need to simulate load of anything like that. – Dima May 12 '21 at 15:03
  • @Dima Thanks for the suggestion. Unfortunately I realized that this implementation does not work. My assumptions about `ThreadPoolExecutor` are probably wrong. I will dismiss the idea of a rate limiting `ExecutionContext` and try something else ... – Michael May 12 '21 at 15:51
  • @Michael why doesn't it work? seems to me like it should ... – Dima May 12 '21 at 16:15
  • @Dima dunno :(( I need to understand how `ThreadPoolExecutor` works internally ... – Michael May 12 '21 at 16:44
  • @Michael I meant what were the symptoms not the cause :) – Dima May 12 '21 at 16:56
  • @Dima Tried a test with an atomic counter (as suggested above) and the elapsed time was not as expected; the elapsed time seems to depend on the number of threads. – Michael May 12 '21 at 17:16
  • 1
    @Dima This is probably the cause : "if fewer than corePoolSize threads are running, the Executor always prefers adding a new thread rather than queuing." (from [javadoc](https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/ThreadPoolExecutor.html)) – Michael May 12 '21 at 19:26