Move InvocationRateLimiter to a separate file

This commit is contained in:
Robert
2023-05-31 12:11:55 +01:00
parent 81fb1264e4
commit b9c0f2bc14
5 changed files with 332 additions and 351 deletions

View File

@@ -6,12 +6,7 @@ import * as semver from "semver";
import { URL } from "url";
import { ExtensionContext, Event } from "vscode";
import { DistributionConfig } from "../config";
import {
InvocationRateLimiter,
InvocationRateLimiterResultKind,
showAndLogErrorMessage,
showAndLogWarningMessage,
} from "../helpers";
import { showAndLogErrorMessage, showAndLogWarningMessage } from "../helpers";
import { extLogger } from "../common";
import { getCodeQlCliVersion } from "./cli-version";
import {
@@ -24,6 +19,10 @@ import {
extractZipArchive,
getRequiredAssetName,
} from "../pure/distribution";
import {
InvocationRateLimiter,
InvocationRateLimiterResultKind,
} from "../invocation-rate-limiter";
/**
* distribution.ts

View File

@@ -10,14 +10,7 @@ import { glob } from "glob";
import { load } from "js-yaml";
import { join, basename, dirname } from "path";
import { dirSync } from "tmp-promise";
import {
ExtensionContext,
Uri,
window as Window,
workspace,
env,
WorkspaceFolder,
} from "vscode";
import { Uri, window as Window, workspace, env, WorkspaceFolder } from "vscode";
import { CodeQLCliServer, QlpacksInfo } from "./codeql-cli/cli";
import { UserCancellationException } from "./common/vscode/progress";
import { extLogger, OutputChannelLogger } from "./common";
@@ -363,106 +356,6 @@ export async function prepareCodeTour(
}
}
/**
* Provides a utility method to invoke a function only if a minimum time interval has elapsed since
* the last invocation of that function.
*/
export class InvocationRateLimiter<T> {
constructor(
extensionContext: ExtensionContext,
funcIdentifier: string,
func: () => Promise<T>,
createDate: (dateString?: string) => Date = (s) =>
s ? new Date(s) : new Date(),
) {
this._createDate = createDate;
this._extensionContext = extensionContext;
this._func = func;
this._funcIdentifier = funcIdentifier;
}
/**
* Invoke the function if `minSecondsSinceLastInvocation` seconds have elapsed since the last invocation.
*/
public async invokeFunctionIfIntervalElapsed(
minSecondsSinceLastInvocation: number,
): Promise<InvocationRateLimiterResult<T>> {
const updateCheckStartDate = this._createDate();
const lastInvocationDate = this.getLastInvocationDate();
if (
minSecondsSinceLastInvocation &&
lastInvocationDate &&
lastInvocationDate <= updateCheckStartDate &&
lastInvocationDate.getTime() + minSecondsSinceLastInvocation * 1000 >
updateCheckStartDate.getTime()
) {
return createRateLimitedResult();
}
const result = await this._func();
await this.setLastInvocationDate(updateCheckStartDate);
return createInvokedResult(result);
}
private getLastInvocationDate(): Date | undefined {
const maybeDateString: string | undefined =
this._extensionContext.globalState.get(
InvocationRateLimiter._invocationRateLimiterPrefix +
this._funcIdentifier,
);
return maybeDateString ? this._createDate(maybeDateString) : undefined;
}
private async setLastInvocationDate(date: Date): Promise<void> {
return await this._extensionContext.globalState.update(
InvocationRateLimiter._invocationRateLimiterPrefix + this._funcIdentifier,
date,
);
}
private readonly _createDate: (dateString?: string) => Date;
private readonly _extensionContext: ExtensionContext;
private readonly _func: () => Promise<T>;
private readonly _funcIdentifier: string;
private static readonly _invocationRateLimiterPrefix =
"invocationRateLimiter_lastInvocationDate_";
}
export enum InvocationRateLimiterResultKind {
Invoked,
RateLimited,
}
/**
* The function was invoked and returned the value `result`.
*/
interface InvokedResult<T> {
kind: InvocationRateLimiterResultKind.Invoked;
result: T;
}
/**
* The function was not invoked as the minimum interval since the last invocation had not elapsed.
*/
interface RateLimitedResult {
kind: InvocationRateLimiterResultKind.RateLimited;
}
type InvocationRateLimiterResult<T> = InvokedResult<T> | RateLimitedResult;
function createInvokedResult<T>(result: T): InvokedResult<T> {
return {
kind: InvocationRateLimiterResultKind.Invoked,
result,
};
}
function createRateLimitedResult(): RateLimitedResult {
return {
kind: InvocationRateLimiterResultKind.RateLimited,
};
}
export interface QlPacksForLanguage {
/** The name of the pack containing the dbscheme. */
dbschemePack: string;

View File

@@ -0,0 +1,91 @@
import { ExtensionContext } from "vscode";
/**
* Provides a utility method to invoke a function only if a minimum time interval has elapsed since
* the last invocation of that function.
*/
export class InvocationRateLimiter<T> {
constructor(
private readonly extensionContext: ExtensionContext,
private readonly funcIdentifier: string,
private readonly func: () => Promise<T>,
private readonly createDate: (dateString?: string) => Date = (s) =>
s ? new Date(s) : new Date(),
) {}
/**
* Invoke the function if `minSecondsSinceLastInvocation` seconds have elapsed since the last invocation.
*/
public async invokeFunctionIfIntervalElapsed(
minSecondsSinceLastInvocation: number,
): Promise<InvocationRateLimiterResult<T>> {
const updateCheckStartDate = this.createDate();
const lastInvocationDate = this.getLastInvocationDate();
if (
minSecondsSinceLastInvocation &&
lastInvocationDate &&
lastInvocationDate <= updateCheckStartDate &&
lastInvocationDate.getTime() + minSecondsSinceLastInvocation * 1000 >
updateCheckStartDate.getTime()
) {
return createRateLimitedResult();
}
const result = await this.func();
await this.setLastInvocationDate(updateCheckStartDate);
return createInvokedResult(result);
}
private getLastInvocationDate(): Date | undefined {
const maybeDateString: string | undefined =
this.extensionContext.globalState.get(
InvocationRateLimiter._invocationRateLimiterPrefix +
this.funcIdentifier,
);
return maybeDateString ? this.createDate(maybeDateString) : undefined;
}
private async setLastInvocationDate(date: Date): Promise<void> {
return await this.extensionContext.globalState.update(
InvocationRateLimiter._invocationRateLimiterPrefix + this.funcIdentifier,
date,
);
}
private static readonly _invocationRateLimiterPrefix =
"invocationRateLimiter_lastInvocationDate_";
}
export enum InvocationRateLimiterResultKind {
Invoked,
RateLimited,
}
/**
* The function was invoked and returned the value `result`.
*/
interface InvokedResult<T> {
kind: InvocationRateLimiterResultKind.Invoked;
result: T;
}
/**
* The function was not invoked as the minimum interval since the last invocation had not elapsed.
*/
interface RateLimitedResult {
kind: InvocationRateLimiterResultKind.RateLimited;
}
type InvocationRateLimiterResult<T> = InvokedResult<T> | RateLimitedResult;
function createInvokedResult<T>(result: T): InvokedResult<T> {
return {
kind: InvocationRateLimiterResultKind.Invoked,
result,
};
}
function createRateLimitedResult(): RateLimitedResult {
return {
kind: InvocationRateLimiterResultKind.RateLimited,
};
}

View File

@@ -1,17 +1,4 @@
import {
EnvironmentVariableCollection,
EnvironmentVariableMutator,
Event,
ExtensionContext,
ExtensionMode,
Memento,
SecretStorage,
SecretStorageChangeEvent,
Uri,
window,
workspace,
WorkspaceFolder,
} from "vscode";
import { Uri, window, workspace, WorkspaceFolder } from "vscode";
import { dump } from "js-yaml";
import * as tmp from "tmp";
import { join } from "path";
@@ -28,7 +15,6 @@ import { DirResult } from "tmp";
import {
getFirstWorkspaceFolder,
getInitialQueryContents,
InvocationRateLimiter,
isFolderAlreadyInWorkspace,
isLikelyDatabaseRoot,
isLikelyDbLanguageFolder,
@@ -45,118 +31,6 @@ import { Setting } from "../../../src/config";
import { createMockCommandManager } from "../../__mocks__/commandsMock";
describe("helpers", () => {
describe("Invocation rate limiter", () => {
// 1 January 2020
let currentUnixTime = 1577836800;
function createDate(dateString?: string): Date {
if (dateString) {
return new Date(dateString);
}
const numMillisecondsPerSecond = 1000;
return new Date(currentUnixTime * numMillisecondsPerSecond);
}
function createInvocationRateLimiter<T>(
funcIdentifier: string,
func: () => Promise<T>,
): InvocationRateLimiter<T> {
return new InvocationRateLimiter(
new MockExtensionContext(),
funcIdentifier,
func,
(s) => createDate(s),
);
}
it("initially invokes function", async () => {
let numTimesFuncCalled = 0;
const invocationRateLimiter = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncCalled++;
},
);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(100);
expect(numTimesFuncCalled).toBe(1);
});
it("doesn't invoke function again if no time has passed", async () => {
let numTimesFuncCalled = 0;
const invocationRateLimiter = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncCalled++;
},
);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(100);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(100);
expect(numTimesFuncCalled).toBe(1);
});
it("doesn't invoke function again if requested time since last invocation hasn't passed", async () => {
let numTimesFuncCalled = 0;
const invocationRateLimiter = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncCalled++;
},
);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(100);
currentUnixTime += 1;
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(2);
expect(numTimesFuncCalled).toBe(1);
});
it("invokes function again immediately if requested time since last invocation is 0 seconds", async () => {
let numTimesFuncCalled = 0;
const invocationRateLimiter = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncCalled++;
},
);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(0);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(0);
expect(numTimesFuncCalled).toBe(2);
});
it("invokes function again after requested time since last invocation has elapsed", async () => {
let numTimesFuncCalled = 0;
const invocationRateLimiter = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncCalled++;
},
);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(1);
currentUnixTime += 1;
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(1);
expect(numTimesFuncCalled).toBe(2);
});
it("invokes functions with different rate limiters", async () => {
let numTimesFuncACalled = 0;
const invocationRateLimiterA = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncACalled++;
},
);
let numTimesFuncBCalled = 0;
const invocationRateLimiterB = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncBCalled++;
},
);
await invocationRateLimiterA.invokeFunctionIfIntervalElapsed(100);
await invocationRateLimiterB.invokeFunctionIfIntervalElapsed(100);
expect(numTimesFuncACalled).toBe(1);
expect(numTimesFuncBCalled).toBe(1);
});
});
describe("codeql-database.yml tests", () => {
let dir: tmp.DirResult;
let language: QueryLanguage;
@@ -250,116 +124,6 @@ describe("helpers", () => {
});
});
class MockExtensionContext implements ExtensionContext {
extensionMode: ExtensionMode = 3;
subscriptions: Array<{ dispose(): unknown }> = [];
workspaceState: Memento = new MockMemento();
globalState = new MockGlobalStorage();
extensionPath = "";
asAbsolutePath(_relativePath: string): string {
throw new Error("Method not implemented.");
}
storagePath = "";
globalStoragePath = "";
logPath = "";
extensionUri = Uri.parse("");
environmentVariableCollection = new MockEnvironmentVariableCollection();
secrets = new MockSecretStorage();
storageUri = Uri.parse("");
globalStorageUri = Uri.parse("");
logUri = Uri.parse("");
extension: any;
}
class MockEnvironmentVariableCollection
implements EnvironmentVariableCollection
{
[Symbol.iterator](): Iterator<
[variable: string, mutator: EnvironmentVariableMutator],
any,
undefined
> {
throw new Error("Method not implemented.");
}
persistent = false;
replace(_variable: string, _value: string): void {
throw new Error("Method not implemented.");
}
append(_variable: string, _value: string): void {
throw new Error("Method not implemented.");
}
prepend(_variable: string, _value: string): void {
throw new Error("Method not implemented.");
}
get(_variable: string): EnvironmentVariableMutator | undefined {
throw new Error("Method not implemented.");
}
forEach(
_callback: (
variable: string,
mutator: EnvironmentVariableMutator,
collection: EnvironmentVariableCollection,
) => any,
_thisArg?: any,
): void {
throw new Error("Method not implemented.");
}
delete(_variable: string): void {
throw new Error("Method not implemented.");
}
clear(): void {
throw new Error("Method not implemented.");
}
}
class MockMemento implements Memento {
keys(): readonly string[] {
throw new Error("Method not implemented.");
}
map = new Map<any, any>();
/**
* Return a value.
*
* @param key A string.
* @param defaultValue A value that should be returned when there is no
* value (`undefined`) with the given key.
* @return The stored value or the defaultValue.
*/
get<T>(key: string, defaultValue?: T): T {
return this.map.has(key) ? this.map.get(key) : defaultValue;
}
/**
* Store a value. The value must be JSON-stringifyable.
*
* @param key A string.
* @param value A value. MUST not contain cyclic references.
*/
async update(key: string, value: any): Promise<void> {
this.map.set(key, value);
}
}
class MockGlobalStorage extends MockMemento {
public setKeysForSync(_keys: string[]): void {
return;
}
}
class MockSecretStorage implements SecretStorage {
get(_key: string): Thenable<string | undefined> {
throw new Error("Method not implemented.");
}
store(_key: string, _value: string): Thenable<void> {
throw new Error("Method not implemented.");
}
delete(_key: string): Thenable<void> {
throw new Error("Method not implemented.");
}
onDidChange!: Event<SecretStorageChangeEvent>;
}
it("should report stream progress", () => {
const progressSpy = jest.fn();
const mockReadable = {

View File

@@ -0,0 +1,234 @@
import {
EnvironmentVariableCollection,
EnvironmentVariableMutator,
Event,
ExtensionContext,
ExtensionMode,
Memento,
SecretStorage,
SecretStorageChangeEvent,
Uri,
} from "vscode";
import { InvocationRateLimiter } from "../../../src/invocation-rate-limiter";
describe("Invocation rate limiter", () => {
// 1 January 2020
let currentUnixTime = 1577836800;
function createDate(dateString?: string): Date {
if (dateString) {
return new Date(dateString);
}
const numMillisecondsPerSecond = 1000;
return new Date(currentUnixTime * numMillisecondsPerSecond);
}
function createInvocationRateLimiter<T>(
funcIdentifier: string,
func: () => Promise<T>,
): InvocationRateLimiter<T> {
return new InvocationRateLimiter(
new MockExtensionContext(),
funcIdentifier,
func,
(s) => createDate(s),
);
}
class MockExtensionContext implements ExtensionContext {
extensionMode: ExtensionMode = 3;
subscriptions: Array<{ dispose(): unknown }> = [];
workspaceState: Memento = new MockMemento();
globalState = new MockGlobalStorage();
extensionPath = "";
asAbsolutePath(_relativePath: string): string {
throw new Error("Method not implemented.");
}
storagePath = "";
globalStoragePath = "";
logPath = "";
extensionUri = Uri.parse("");
environmentVariableCollection = new MockEnvironmentVariableCollection();
secrets = new MockSecretStorage();
storageUri = Uri.parse("");
globalStorageUri = Uri.parse("");
logUri = Uri.parse("");
extension: any;
}
class MockEnvironmentVariableCollection
implements EnvironmentVariableCollection
{
[Symbol.iterator](): Iterator<
[variable: string, mutator: EnvironmentVariableMutator],
any,
undefined
> {
throw new Error("Method not implemented.");
}
persistent = false;
replace(_variable: string, _value: string): void {
throw new Error("Method not implemented.");
}
append(_variable: string, _value: string): void {
throw new Error("Method not implemented.");
}
prepend(_variable: string, _value: string): void {
throw new Error("Method not implemented.");
}
get(_variable: string): EnvironmentVariableMutator | undefined {
throw new Error("Method not implemented.");
}
forEach(
_callback: (
variable: string,
mutator: EnvironmentVariableMutator,
collection: EnvironmentVariableCollection,
) => any,
_thisArg?: any,
): void {
throw new Error("Method not implemented.");
}
delete(_variable: string): void {
throw new Error("Method not implemented.");
}
clear(): void {
throw new Error("Method not implemented.");
}
}
class MockMemento implements Memento {
keys(): readonly string[] {
throw new Error("Method not implemented.");
}
map = new Map<any, any>();
/**
* Return a value.
*
* @param key A string.
* @param defaultValue A value that should be returned when there is no
* value (`undefined`) with the given key.
* @return The stored value or the defaultValue.
*/
get<T>(key: string, defaultValue?: T): T {
return this.map.has(key) ? this.map.get(key) : defaultValue;
}
/**
* Store a value. The value must be JSON-stringifyable.
*
* @param key A string.
* @param value A value. MUST not contain cyclic references.
*/
async update(key: string, value: any): Promise<void> {
this.map.set(key, value);
}
}
class MockGlobalStorage extends MockMemento {
public setKeysForSync(_keys: string[]): void {
return;
}
}
class MockSecretStorage implements SecretStorage {
get(_key: string): Thenable<string | undefined> {
throw new Error("Method not implemented.");
}
store(_key: string, _value: string): Thenable<void> {
throw new Error("Method not implemented.");
}
delete(_key: string): Thenable<void> {
throw new Error("Method not implemented.");
}
onDidChange!: Event<SecretStorageChangeEvent>;
}
it("initially invokes function", async () => {
let numTimesFuncCalled = 0;
const invocationRateLimiter = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncCalled++;
},
);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(100);
expect(numTimesFuncCalled).toBe(1);
});
it("doesn't invoke function again if no time has passed", async () => {
let numTimesFuncCalled = 0;
const invocationRateLimiter = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncCalled++;
},
);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(100);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(100);
expect(numTimesFuncCalled).toBe(1);
});
it("doesn't invoke function again if requested time since last invocation hasn't passed", async () => {
let numTimesFuncCalled = 0;
const invocationRateLimiter = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncCalled++;
},
);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(100);
currentUnixTime += 1;
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(2);
expect(numTimesFuncCalled).toBe(1);
});
it("invokes function again immediately if requested time since last invocation is 0 seconds", async () => {
let numTimesFuncCalled = 0;
const invocationRateLimiter = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncCalled++;
},
);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(0);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(0);
expect(numTimesFuncCalled).toBe(2);
});
it("invokes function again after requested time since last invocation has elapsed", async () => {
let numTimesFuncCalled = 0;
const invocationRateLimiter = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncCalled++;
},
);
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(1);
currentUnixTime += 1;
await invocationRateLimiter.invokeFunctionIfIntervalElapsed(1);
expect(numTimesFuncCalled).toBe(2);
});
it("invokes functions with different rate limiters", async () => {
let numTimesFuncACalled = 0;
const invocationRateLimiterA = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncACalled++;
},
);
let numTimesFuncBCalled = 0;
const invocationRateLimiterB = createInvocationRateLimiter(
"funcid",
async () => {
numTimesFuncBCalled++;
},
);
await invocationRateLimiterA.invokeFunctionIfIntervalElapsed(100);
await invocationRateLimiterB.invokeFunctionIfIntervalElapsed(100);
expect(numTimesFuncACalled).toBe(1);
expect(numTimesFuncBCalled).toBe(1);
});
});