Skip to content

Commit ca0abbc

Browse files
author
Beatriz Rizental
authored
Merge pull request #658 from brizental/1712920-rate-limit
Bug 1712920 - Implement rate limiting in ping upload
2 parents 40b66d6 + 268feaf commit ca0abbc

File tree

5 files changed

+263
-9
lines changed

5 files changed

+263
-9
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
* [#580](https://github.com/mozilla/glean.js/pull/580): BUGFIX: Pending pings at startup up are uploaded from oldest to newest.
88
* [#607](https://github.com/mozilla/glean.js/pull/607): Record an error when incoherent timestamps are calculated for events after a restart.
99
* [#630](https://github.com/mozilla/glean.js/pull/630): Accept booleans and numbers as event extras.
10+
* [#658](https://github.com/mozilla/glean.js/pull/658): Implement rate limiting for ping upload.
11+
* Only up to 15 ping submissions every 60 seconds are now allowed.
12+
* [#658](https://github.com/mozilla/glean.js/pull/658): BUGFIX: Unblock ping uploading jobs after the maximum of upload failures are hit for a given uploading window.
1013

1114
# v0.18.1 (2021-07-22)
1215

glean/src/core/upload/index.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,15 @@ import type PlatformInfo from "../platform_info.js";
2121
import { UploadResult } from "./uploader.js";
2222
import type Uploader from "./uploader.js";
2323
import { UploadResultStatus } from "./uploader.js";
24+
import RateLimiter, { RateLimiterState } from "./rate_limiter.js";
2425

2526
const LOG_TAG = "core.Upload";
2627

28+
// Default rate limiter interval, in milliseconds.
29+
const RATE_LIMITER_INTERVAL_MS = 60 * 1000;
30+
// Default max pings per internal.
31+
const MAX_PINGS_PER_INTERVAL = 15;
32+
2733
/**
2834
* Create and initialize a dispatcher for the PingUplaoder.
2935
*
@@ -91,7 +97,8 @@ class PingUploader implements PingsDatabaseObserver {
9197
config: Configuration,
9298
platform: Platform,
9399
private readonly pingsDatabase = Context.pingsDatabase,
94-
private readonly policy = new Policy()
100+
private readonly policy = new Policy(),
101+
private readonly rateLimiter = new RateLimiter(RATE_LIMITER_INTERVAL_MS, MAX_PINGS_PER_INTERVAL)
95102
) {
96103
this.processing = [];
97104
// Initialize the ping uploader with either the platform defaults or a custom
@@ -121,6 +128,35 @@ class PingUploader implements PingsDatabaseObserver {
121128
// Add the ping to the list of pings being processsed.
122129
this.processing.push(ping);
123130

131+
const { state: rateLimiterState, remainingTime } = this.rateLimiter.getState();
132+
if (rateLimiterState === RateLimiterState.Incrementing) {
133+
this.dispatcher.resume();
134+
} else {
135+
this.dispatcher.stop();
136+
137+
if (rateLimiterState === RateLimiterState.Throttled) {
138+
log(
139+
LOG_TAG,
140+
[
141+
"Attempted to upload a ping, but Glean is currently throttled.",
142+
`Pending pings will be processed in ${(remainingTime || 0) / 1000}s.`
143+
],
144+
LoggingLevel.Debug
145+
);
146+
}
147+
else if (rateLimiterState === RateLimiterState.Stopped) {
148+
log(
149+
LOG_TAG,
150+
[
151+
"Attempted to upload a ping, but Glean has reached maximum recoverable upload failures",
152+
"for the current uploading window.",
153+
`Will retry in ${(remainingTime || 0) / 1000}s.`
154+
],
155+
LoggingLevel.Debug
156+
);
157+
}
158+
}
159+
124160
// If the ping is a deletion-request ping, we want to enqueue it as a persistent task,
125161
// so that clearing the queue does not clear it.
126162
//
@@ -143,6 +179,7 @@ class PingUploader implements PingsDatabaseObserver {
143179
`Reached maximum recoverable failures for ping "${JSON.stringify(ping.name)}". You are done.`,
144180
LoggingLevel.Info
145181
);
182+
this.rateLimiter.stop();
146183
this.dispatcher.stop();
147184
ping.retries = 0;
148185
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { isUndefined, getMonotonicNow } from "../utils.js";
6+
7+
/**
8+
* An enum to represent the current state of the RateLimiter.
9+
*/
10+
export const enum RateLimiterState {
11+
// The RateLimiter has not reached the maximum count and is still incrementing.
12+
Incrementing,
13+
// The RateLimiter has not reached the maximum count, but it is also not incrementing.
14+
Stopped,
15+
// The RateLimiter has reached the maximum count for the current interval.
16+
Throttled,
17+
}
18+
19+
class RateLimiter {
20+
// Whether or not the RateLimiter is not counting any further for the current interval.
21+
// This is different from the RateLimiter being throttled, because it may happen
22+
// even if max count for the current interval has not been reached.
23+
private stopped = false;
24+
25+
constructor(
26+
// The duration of each interval, in millisecods.
27+
private interval: number,
28+
// The maximum count per interval.
29+
private maxCount: number,
30+
// The count for the current interval.
31+
private count: number = 0,
32+
// The instant the current interval has started, in milliseconds.
33+
private started?: number,
34+
) {}
35+
36+
get elapsed(): number {
37+
if (isUndefined(this.started)) {
38+
return NaN;
39+
}
40+
41+
const now = getMonotonicNow();
42+
const elapsed = now - this.started;
43+
44+
// It's very unlikely elapsed will be a negative number since we are using a monotonic timer
45+
// here, but just to be extra sure, we account for it.
46+
if (elapsed < 0) {
47+
return NaN;
48+
}
49+
50+
return elapsed;
51+
}
52+
53+
private reset(): void {
54+
this.started = getMonotonicNow();
55+
this.count = 0;
56+
this.stopped = false;
57+
}
58+
59+
/**
60+
* The rate limiter should reset if
61+
*
62+
* 1. It has never started i.e. `started` is still `undefined`;
63+
* 2. It has been started more than the interval time ago;
64+
* 3. Something goes wrong while trying to calculate the elapsed time since the last reset.
65+
*
66+
* @returns Whether or not this rate limiter should reset.
67+
*/
68+
private shouldReset(): boolean {
69+
if (isUndefined(this.started)) {
70+
return true;
71+
}
72+
73+
if (isNaN(this.elapsed) || this.elapsed > this.interval) {
74+
return true;
75+
}
76+
77+
return false;
78+
}
79+
80+
/**
81+
* Tries to increment the internal counter.
82+
*
83+
* @returns The current state of the RateLimiter plus the remaining time
84+
* (in milliseconds) until the end of the current window.
85+
*/
86+
getState(): {
87+
state: RateLimiterState,
88+
remainingTime?: number,
89+
} {
90+
if (this.shouldReset()) {
91+
this.reset();
92+
}
93+
94+
const remainingTime = this.interval - this.elapsed;
95+
if (this.stopped) {
96+
return {
97+
state: RateLimiterState.Stopped,
98+
remainingTime,
99+
};
100+
}
101+
102+
if (this.count >= this.maxCount) {
103+
return {
104+
state: RateLimiterState.Throttled,
105+
remainingTime,
106+
};
107+
}
108+
109+
this.count++;
110+
return {
111+
state: RateLimiterState.Incrementing
112+
};
113+
}
114+
115+
/**
116+
* Stops counting for the current interval, regardless of the max count being reached.
117+
*
118+
* The RateLimiter will still be reset when time interval is over.
119+
*/
120+
stop(): void {
121+
this.stopped = true;
122+
}
123+
}
124+
125+
export default RateLimiter;

glean/tests/unit/core/upload/index.spec.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,12 @@ describe("PingUploader", function() {
137137
assert.deepStrictEqual(Object.keys(allPings).length, 1);
138138
});
139139

140-
it("duplicates are not enqueued", function() {
141-
// Don't initialize to keep the dispatcher in an uninitialized state
142-
// thus making sure no upload attempt is executed and we can look at the dispatcher queue.
143-
const uploader = new PingUploader(new Configuration(), Glean.platform, Context.pingsDatabase);
144-
// Stop the dispatcher so that pings can be enqueued but not sent.
145-
uploader["dispatcher"].stop();
140+
it("duplicates are not enqueued", async function() {
141+
const httpClient = new CounterUploader();
142+
await Glean.testResetGlean(testAppId, true, { httpClient });
146143

147144
for (let i = 0; i < 10; i++) {
148-
uploader["enqueuePing"]({
145+
Glean["pingUploader"]["enqueuePing"]({
149146
collectionDate: (new Date()).toISOString(),
150147
identifier: "id",
151148
retries: 0,
@@ -163,7 +160,8 @@ describe("PingUploader", function() {
163160
});
164161
}
165162

166-
assert.strictEqual(uploader["dispatcher"]["queue"].length, 1);
163+
await Glean["pingUploader"].testBlockOnPingsQueue();
164+
assert.strictEqual(httpClient.count, 1);
167165
});
168166

169167
it("maximum of recoverable errors is enforced", async function () {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import assert from "assert";
6+
import type { SinonFakeTimers } from "sinon";
7+
import sinon from "sinon";
8+
9+
import RateLimiter, { RateLimiterState } from "../../../../src/core/upload/rate_limiter";
10+
11+
const sandbox = sinon.createSandbox();
12+
const now = new Date();
13+
14+
15+
describe("RateLimiter", function() {
16+
let clock: SinonFakeTimers;
17+
18+
beforeEach(function() {
19+
clock = sandbox.useFakeTimers(now.getTime());
20+
});
21+
22+
afterEach(function () {
23+
clock.restore();
24+
});
25+
26+
it("rate limiter correctly resets in case elapsed time return an error", function () {
27+
const rateLimiter = new RateLimiter(
28+
1000, /* interval */
29+
3, /* maxCount */
30+
);
31+
32+
// Reach the count for the current interval.
33+
assert.deepStrictEqual(rateLimiter.getState(), { state: RateLimiterState.Incrementing });
34+
assert.deepStrictEqual(rateLimiter.getState(), { state: RateLimiterState.Incrementing });
35+
assert.deepStrictEqual(rateLimiter.getState(), { state: RateLimiterState.Incrementing });
36+
37+
sinon.replaceGetter(rateLimiter, "elapsed", () => NaN);
38+
assert.deepStrictEqual(rateLimiter.getState(), { state: RateLimiterState.Incrementing });
39+
});
40+
41+
it("rate limiter correctly resets in case interval is over", function () {
42+
const rateLimiter = new RateLimiter(
43+
1000, /* interval */
44+
3, /* maxCount */
45+
);
46+
47+
// Reach the count for the current interval.
48+
assert.deepStrictEqual(rateLimiter.getState(), { state: RateLimiterState.Incrementing });
49+
assert.deepStrictEqual(rateLimiter.getState(), { state: RateLimiterState.Incrementing });
50+
assert.deepStrictEqual(rateLimiter.getState(), { state: RateLimiterState.Incrementing });
51+
52+
// Fake the time passing over the current interval
53+
sinon.replaceGetter(rateLimiter, "elapsed", () => 1000 * 2);
54+
assert.deepStrictEqual(rateLimiter.getState(), { state: RateLimiterState.Incrementing });
55+
});
56+
57+
it("rate limiter returns throttled state when it is throttled", function () {
58+
const rateLimiter = new RateLimiter(
59+
1000, /* interval */
60+
3, /* maxCount */
61+
);
62+
63+
// Reach the count for the current interval.
64+
assert.deepStrictEqual(rateLimiter.getState(), { state: RateLimiterState.Incrementing });
65+
assert.deepStrictEqual(rateLimiter.getState(), { state: RateLimiterState.Incrementing });
66+
assert.deepStrictEqual(rateLimiter.getState(), { state: RateLimiterState.Incrementing });
67+
68+
// Try one more time and we should be throttled.
69+
const nextState = rateLimiter.getState();
70+
assert.strictEqual(nextState.state, RateLimiterState.Throttled);
71+
assert.ok(nextState.remainingTime as number <= 1000 && nextState.remainingTime as number > 0);
72+
});
73+
74+
it("rate limiter returns stopped state when it is stopped", function () {
75+
const rateLimiter = new RateLimiter(
76+
1000, /* interval */
77+
3, /* maxCount */
78+
);
79+
80+
// Don't reach the count for the current interval.
81+
assert.deepStrictEqual(rateLimiter.getState(), { state: RateLimiterState.Incrementing });
82+
83+
// Stop the rate limiter
84+
rateLimiter.stop();
85+
86+
// Try one more time and we should be stopped.
87+
const nextState = rateLimiter.getState();
88+
assert.strictEqual(nextState.state, RateLimiterState.Stopped);
89+
assert.ok(nextState.remainingTime as number <= 1000 && nextState.remainingTime as number > 0);
90+
});
91+
});

0 commit comments

Comments
 (0)