Skip to content

Commit 47d4a11

Browse files
author
Brian Chen
authored
fix: add RateLimiter (#230)
1 parent 7e4568d commit 47d4a11

File tree

2 files changed

+250
-0
lines changed

2 files changed

+250
-0
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
* Copyright 2020 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.firestore;
18+
19+
import com.google.common.base.Preconditions;
20+
import java.util.Date;
21+
22+
/**
23+
* A helper that uses the Token Bucket algorithm to rate limit the number of operations that can be
24+
* made in a second.
25+
*
26+
* <p>Before a given request containing a number of operations can proceed, RateLimiter determines
27+
* doing so stays under the provided rate limits. It can also determine how much time is required
28+
* before a request can be made.
29+
*
30+
* <p>RateLimiter can also implement a gradually increasing rate limit. This is used to enforce the
31+
* 500/50/5 rule.
32+
*
33+
* @see <a href=https://cloud.google.com/datastore/docs/best-practices#ramping_up_traffic>Ramping up
34+
* traffic</a>
35+
*/
36+
class RateLimiter {
37+
private final int initialCapacity;
38+
private final double multiplier;
39+
private final int multiplierMillis;
40+
private final long startTimeMillis;
41+
42+
private int availableTokens;
43+
private long lastRefillTimeMillis;
44+
45+
RateLimiter(int initialCapacity, int multiplier, int multiplierMillis) {
46+
this(initialCapacity, multiplier, multiplierMillis, new Date().getTime());
47+
}
48+
49+
/**
50+
* @param initialCapacity Initial maximum number of operations per second.
51+
* @param multiplier Rate by which to increase the capacity.
52+
* @param multiplierMillis How often the capacity should increase in milliseconds.
53+
* @param startTimeMillis The starting time in epoch milliseconds that the rate limit is based on.
54+
* Used for testing the limiter.
55+
*/
56+
RateLimiter(int initialCapacity, double multiplier, int multiplierMillis, long startTimeMillis) {
57+
this.initialCapacity = initialCapacity;
58+
this.multiplier = multiplier;
59+
this.multiplierMillis = multiplierMillis;
60+
this.startTimeMillis = startTimeMillis;
61+
62+
this.availableTokens = initialCapacity;
63+
this.lastRefillTimeMillis = startTimeMillis;
64+
}
65+
66+
public boolean tryMakeRequest(int numOperations) {
67+
return tryMakeRequest(numOperations, new Date(0).getTime());
68+
}
69+
70+
/**
71+
* Tries to make the number of operations. Returns true if the request succeeded and false
72+
* otherwise.
73+
*
74+
* @param requestTimeMillis The time used to calculate the number of available tokens. Used for
75+
* testing the limiter.
76+
*/
77+
public boolean tryMakeRequest(int numOperations, long requestTimeMillis) {
78+
refillTokens(requestTimeMillis);
79+
if (numOperations <= availableTokens) {
80+
availableTokens -= numOperations;
81+
return true;
82+
}
83+
return false;
84+
}
85+
86+
public long getNextRequestDelayMs(int numOperations) {
87+
return getNextRequestDelayMs(numOperations, new Date().getTime());
88+
}
89+
90+
/**
91+
* Returns the number of ms needed to make a request with the provided number of operations.
92+
* Returns 0 if the request can be made with the existing capacity. Returns -1 if the request is
93+
* not possible with the current capacity.
94+
*
95+
* @param requestTimeMillis The time used to calculate the number of available tokens. Used for
96+
* testing the limiter.
97+
*/
98+
public long getNextRequestDelayMs(int numOperations, long requestTimeMillis) {
99+
if (numOperations < availableTokens) {
100+
return 0;
101+
}
102+
103+
int capacity = calculateCapacity(requestTimeMillis);
104+
if (capacity < numOperations) {
105+
return -1;
106+
}
107+
108+
int requiredTokens = numOperations - availableTokens;
109+
return (long) Math.ceil((double) (requiredTokens * 1000) / capacity);
110+
}
111+
112+
/**
113+
* Refills the number of available tokens based on how much time has elapsed since the last time
114+
* the tokens were refilled.
115+
*
116+
* @param requestTimeMillis The time used to calculate the number of available tokens. Used for
117+
* testing the limiter.
118+
*/
119+
private void refillTokens(long requestTimeMillis) {
120+
Preconditions.checkArgument(
121+
requestTimeMillis >= lastRefillTimeMillis,
122+
"Request time should not be before the last token refill time");
123+
long elapsedTime = requestTimeMillis - lastRefillTimeMillis;
124+
int capacity = calculateCapacity(requestTimeMillis);
125+
int tokensToAdd = (int) ((elapsedTime * capacity) / 1000);
126+
if (tokensToAdd > 0) {
127+
availableTokens = Math.min(capacity, availableTokens + tokensToAdd);
128+
lastRefillTimeMillis = requestTimeMillis;
129+
}
130+
}
131+
132+
public int calculateCapacity(long requestTimeMillis) {
133+
long millisElapsed = requestTimeMillis - startTimeMillis;
134+
int operationsPerSecond =
135+
(int) (Math.pow(multiplier, (int) (millisElapsed / multiplierMillis)) * initialCapacity);
136+
return operationsPerSecond;
137+
}
138+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Copyright 2020 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.firestore;
18+
19+
import static org.junit.Assert.assertEquals;
20+
import static org.junit.Assert.assertFalse;
21+
import static org.junit.Assert.assertTrue;
22+
import static org.junit.Assert.fail;
23+
24+
import java.util.Date;
25+
import org.junit.Before;
26+
import org.junit.Test;
27+
import org.junit.runner.RunWith;
28+
import org.mockito.runners.MockitoJUnitRunner;
29+
30+
@RunWith(MockitoJUnitRunner.class)
31+
public class RateLimiterTest {
32+
private RateLimiter limiter;
33+
34+
@Before
35+
public void before() {
36+
limiter =
37+
new RateLimiter(
38+
/* initialCapacity= */ 500,
39+
/* multiplier= */ 1.5,
40+
/* multiplierMillis= */ 5 * 60 * 1000,
41+
/* startTime= */ new Date(0).getTime());
42+
}
43+
44+
@Test
45+
public void processRequestsFromCapacity() {
46+
assertTrue(limiter.tryMakeRequest(250, new Date(0).getTime()));
47+
assertTrue(limiter.tryMakeRequest(250, new Date(0).getTime()));
48+
49+
// Once tokens have been used, further requests should fail.
50+
assertFalse(limiter.tryMakeRequest(1, new Date(0).getTime()));
51+
52+
// Tokens will only refill up to max capacity.
53+
assertFalse(limiter.tryMakeRequest(501, new Date(1 * 1000).getTime()));
54+
assertTrue(limiter.tryMakeRequest(500, new Date(1 * 1000).getTime()));
55+
56+
// Tokens will refill incrementally based on number of ms elapsed.
57+
assertFalse(limiter.tryMakeRequest(250, new Date(1 * 1000 + 499).getTime()));
58+
assertTrue(limiter.tryMakeRequest(249, new Date(1 * 1000 + 500).getTime()));
59+
60+
// Scales with multiplier.
61+
assertFalse(limiter.tryMakeRequest(751, new Date((5 * 60 - 1) * 1000).getTime()));
62+
assertFalse(limiter.tryMakeRequest(751, new Date(5 * 60 * 1000).getTime()));
63+
assertTrue(limiter.tryMakeRequest(750, new Date(5 * 60 * 1000).getTime()));
64+
65+
// Tokens will never exceed capacity.
66+
assertFalse(limiter.tryMakeRequest(751, new Date((5 * 60 + 3) * 1000).getTime()));
67+
68+
// Rejects requests made before lastRefillTime.
69+
try {
70+
limiter.tryMakeRequest(751, new Date((5 * 60 + 2) * 1000).getTime());
71+
fail();
72+
} catch (IllegalArgumentException e) {
73+
assertEquals("Request time should not be before the last token refill time", e.getMessage());
74+
}
75+
}
76+
77+
@Test
78+
public void calculatesMsForNextRequest() {
79+
// Should return 0 if there are enough tokens for the request to be made.
80+
long timestamp = new Date(0).getTime();
81+
assertEquals(0, limiter.getNextRequestDelayMs(500, timestamp));
82+
83+
// Should factor in remaining tokens when calculating the time.
84+
assertTrue(limiter.tryMakeRequest(250, timestamp));
85+
assertEquals(500, limiter.getNextRequestDelayMs(500, timestamp));
86+
87+
// Once tokens have been used, should calculate time before next request.
88+
timestamp = new Date(1 * 1000).getTime();
89+
assertTrue(limiter.tryMakeRequest(500, timestamp));
90+
assertEquals(200, limiter.getNextRequestDelayMs(100, timestamp));
91+
assertEquals(500, limiter.getNextRequestDelayMs(250, timestamp));
92+
assertEquals(1000, limiter.getNextRequestDelayMs(500, timestamp));
93+
assertEquals(-1, limiter.getNextRequestDelayMs(501, timestamp));
94+
95+
// Scales with multiplier.
96+
timestamp = new Date(5 * 60 * 1000).getTime();
97+
assertTrue(limiter.tryMakeRequest(750, timestamp));
98+
assertEquals(334, limiter.getNextRequestDelayMs(250, timestamp));
99+
assertEquals(667, limiter.getNextRequestDelayMs(500, timestamp));
100+
assertEquals(1000, limiter.getNextRequestDelayMs(750, timestamp));
101+
assertEquals(-1, limiter.getNextRequestDelayMs(751, timestamp));
102+
}
103+
104+
@Test
105+
public void calculatesMaxOperations() {
106+
assertEquals(500, limiter.calculateCapacity(new Date(0).getTime()));
107+
assertEquals(750, limiter.calculateCapacity(new Date(5 * 60 * 1000).getTime()));
108+
assertEquals(1125, limiter.calculateCapacity(new Date(10 * 60 * 1000).getTime()));
109+
assertEquals(1687, limiter.calculateCapacity(new Date(15 * 60 * 1000).getTime()));
110+
assertEquals(738945, limiter.calculateCapacity(new Date(90 * 60 * 1000).getTime()));
111+
}
112+
}

0 commit comments

Comments
 (0)