Skip to content

Commit 4df7ef4

Browse files
committed
Implement rate limiter for function invocations
1 parent 443eafe commit 4df7ef4

File tree

2 files changed

+183
-1
lines changed

2 files changed

+183
-1
lines changed

extensions/ql-vscode/src/helpers.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as path from 'path';
2-
import { CancellationToken, ProgressOptions, window as Window, workspace } from 'vscode';
2+
import { CancellationToken, ExtensionContext, ProgressOptions, window as Window, workspace } from 'vscode';
33
import { logger } from './logging';
44
import { EvaluationInfo } from './queries';
55

@@ -134,3 +134,81 @@ export function getQueryName(info: EvaluationInfo) {
134134
return path.basename(info.query.program.queryPath);
135135
}
136136
}
137+
138+
/**
139+
* Provides a utility method to invoke a function only if a minimum time interval has elapsed since
140+
* the last invocation of that function.
141+
*/
142+
export class InvocationRateLimiter<T> {
143+
constructor(extensionContext: ExtensionContext, funcIdentifier: string, func: () => Promise<T>) {
144+
this._extensionContext = extensionContext;
145+
this._func = func;
146+
this._funcIdentifier = funcIdentifier;
147+
}
148+
149+
/**
150+
* Invoke the function if `minSecondsSinceLastInvocation` seconds have elapsed since the last invocation.
151+
*/
152+
public async invokeFunctionIfIntervalElapsed(minSecondsSinceLastInvocation: number): Promise<InvocationRateLimiterResult<T>> {
153+
const updateCheckStartDate = new Date();
154+
const lastInvocationDate = this.getLastInvocationDate();
155+
if (minSecondsSinceLastInvocation && lastInvocationDate && lastInvocationDate <= updateCheckStartDate &&
156+
lastInvocationDate.getTime() + minSecondsSinceLastInvocation * 1000 > updateCheckStartDate.getTime()) {
157+
return createRateLimitedResult();
158+
}
159+
const result = await this._func();
160+
await this.setLastInvocationDate(updateCheckStartDate);
161+
return createInvokedResult(result);
162+
}
163+
164+
private getLastInvocationDate(): Date | undefined {
165+
const maybeDate: Date | undefined =
166+
this._extensionContext.globalState.get(InvocationRateLimiter._invocationRateLimiterPrefix + this._funcIdentifier);
167+
return maybeDate ? new Date(maybeDate) : undefined;
168+
}
169+
170+
private async setLastInvocationDate(date: Date): Promise<void> {
171+
return await this._extensionContext.globalState.update(InvocationRateLimiter._invocationRateLimiterPrefix + this._funcIdentifier, date);
172+
}
173+
174+
private readonly _extensionContext: ExtensionContext;
175+
private readonly _func: () => Promise<T>;
176+
private readonly _funcIdentifier: string;
177+
178+
private static readonly _invocationRateLimiterPrefix = "invocationRateLimiter_lastInvocationDate_";
179+
}
180+
181+
export enum InvocationRateLimiterResultKind {
182+
Invoked,
183+
RateLimited
184+
}
185+
186+
/**
187+
* The function was invoked and returned the value `result`.
188+
*/
189+
interface InvokedResult<T> {
190+
kind: InvocationRateLimiterResultKind.Invoked,
191+
result: T
192+
}
193+
194+
/**
195+
* The function was not invoked as the minimum interval since the last invocation had not elapsed.
196+
*/
197+
interface RateLimitedResult {
198+
kind: InvocationRateLimiterResultKind.RateLimited
199+
}
200+
201+
type InvocationRateLimiterResult<T> = InvokedResult<T> | RateLimitedResult;
202+
203+
function createInvokedResult<T>(result: T): InvokedResult<T> {
204+
return {
205+
kind: InvocationRateLimiterResultKind.Invoked,
206+
result
207+
};
208+
}
209+
210+
function createRateLimitedResult(): RateLimitedResult {
211+
return {
212+
kind: InvocationRateLimiterResultKind.RateLimited
213+
};
214+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { expect } from "chai";
2+
import "mocha";
3+
import { ExtensionContext, Memento } from "vscode";
4+
import { InvocationRateLimiter } from "../../helpers";
5+
6+
describe("Invocation rate limiter", () => {
7+
function createInvocationRateLimiter<T>(funcIdentifier: string, func: () => Promise<T>): InvocationRateLimiter<T> {
8+
return new InvocationRateLimiter(new MockExtensionContext(), funcIdentifier, func);
9+
}
10+
11+
it("initially invokes function", async () => {
12+
let numTimesFuncCalled = 0;
13+
const invocationRateLimiter = createInvocationRateLimiter("funcid", async () => {
14+
numTimesFuncCalled++;
15+
});
16+
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(100);
17+
expect(numTimesFuncCalled).to.equal(1);
18+
});
19+
20+
it("doesn't invoke function within time period", async () => {
21+
let numTimesFuncCalled = 0;
22+
const invocationRateLimiter = createInvocationRateLimiter("funcid", async () => {
23+
numTimesFuncCalled++;
24+
});
25+
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(100);
26+
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(100);
27+
expect(numTimesFuncCalled).to.equal(1);
28+
});
29+
30+
it("invoke function again after 0s time period has elapsed", async () => {
31+
let numTimesFuncCalled = 0;
32+
const invocationRateLimiter = createInvocationRateLimiter("funcid", async () => {
33+
numTimesFuncCalled++;
34+
});
35+
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(0);
36+
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(0);
37+
expect(numTimesFuncCalled).to.equal(2);
38+
});
39+
40+
it("invoke function again after 1s time period has elapsed", async () => {
41+
let numTimesFuncCalled = 0;
42+
const invocationRateLimiter = createInvocationRateLimiter("funcid", async () => {
43+
numTimesFuncCalled++;
44+
});
45+
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(1);
46+
await new Promise((resolve, _reject) => setTimeout(() => resolve(), 1000));
47+
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(1);
48+
expect(numTimesFuncCalled).to.equal(2);
49+
});
50+
51+
it("invokes functions with different rate limiters", async () => {
52+
let numTimesFuncACalled = 0;
53+
const invocationRateLimiterA = createInvocationRateLimiter("funcid", async () => {
54+
numTimesFuncACalled++;
55+
});
56+
let numTimesFuncBCalled = 0;
57+
const invocationRateLimiterB = createInvocationRateLimiter("funcid", async () => {
58+
numTimesFuncBCalled++;
59+
});
60+
await invocationRateLimiterA.invokeFunctionIfIntervalElapsed(100);
61+
await invocationRateLimiterB.invokeFunctionIfIntervalElapsed(100);
62+
expect(numTimesFuncACalled).to.equal(1);
63+
expect(numTimesFuncBCalled).to.equal(1);
64+
});
65+
});
66+
67+
class MockExtensionContext implements ExtensionContext {
68+
subscriptions: { dispose(): unknown; }[] = [];
69+
workspaceState: Memento = new MockMemento();
70+
globalState: Memento = new MockMemento();
71+
extensionPath: string = "";
72+
asAbsolutePath(_relativePath: string): string {
73+
throw new Error("Method not implemented.");
74+
}
75+
storagePath: string = "";
76+
globalStoragePath: string = "";
77+
logPath: string = "";
78+
}
79+
80+
class MockMemento implements Memento {
81+
map = new Map<any, any>();
82+
83+
/**
84+
* Return a value.
85+
*
86+
* @param key A string.
87+
* @param defaultValue A value that should be returned when there is no
88+
* value (`undefined`) with the given key.
89+
* @return The stored value or the defaultValue.
90+
*/
91+
get<T>(key: string, defaultValue?: T): T {
92+
return this.map.has(key) ? this.map.get(key) : defaultValue;
93+
}
94+
95+
/**
96+
* Store a value. The value must be JSON-stringifyable.
97+
*
98+
* @param key A string.
99+
* @param value A value. MUST not contain cyclic references.
100+
*/
101+
async update(key: string, value: any): Promise<void> {
102+
this.map.set(key, value);
103+
}
104+
}

0 commit comments

Comments
 (0)