Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: rest hash and handler sweeping #7255

Merged
merged 11 commits into from
Jan 17, 2022
23 changes: 22 additions & 1 deletion packages/rest/src/lib/REST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { InternalRequest, RequestData, RequestManager, RequestMethod, RouteLike
import { DefaultRestOptions, RESTEvents } from './utils/constants';
import type { AgentOptions } from 'node:https';
import type { RequestInit, Response } from 'node-fetch';
import type { HashData } from './RequestManager';
import type Collection from '@discordjs/collection';
import type { IHandler } from './handlers/IHandler';

/**
* Options to be passed when creating the REST instance
Expand Down Expand Up @@ -74,6 +77,21 @@ export interface RESTOptions {
* @default '9'
*/
version: string;
/**
* The amount of time in milliseconds that passes between each hash sweep. (defaults to 4h)
* @default 14_400_000
*/
hashSweepInterval: number;
/**
* The maximum amount of time a hash can exist in milliseconds without being hit with a request (defaults to 24h)
* @default 86_400_000
*/
hashLifetime: number;
/**
* The amount of time in milliseconds that passes between each hash sweep. (defaults to 1h)
* @default 3_600_000
*/
handlerSweepInterval: number;
}

/**
Expand Down Expand Up @@ -168,6 +186,8 @@ export interface RestEvents {
response: [request: APIRequest, response: Response];
newListener: [name: string, listener: (...args: any) => void];
removeListener: [name: string, listener: (...args: any) => void];
hashSweep: [sweptHashes: Collection<string, HashData>];
handlerSweep: [sweptHandlers: Collection<string, IHandler>];
}

export interface REST {
Expand Down Expand Up @@ -197,7 +217,8 @@ export class REST extends EventEmitter {
this.requestManager = new RequestManager(options)
.on(RESTEvents.Debug, this.emit.bind(this, RESTEvents.Debug))
.on(RESTEvents.RateLimited, this.emit.bind(this, RESTEvents.RateLimited))
.on(RESTEvents.InvalidRequestWarning, this.emit.bind(this, RESTEvents.InvalidRequestWarning));
.on(RESTEvents.InvalidRequestWarning, this.emit.bind(this, RESTEvents.InvalidRequestWarning))
.on(RESTEvents.HashSweep, this.emit.bind(this, RESTEvents.HashSweep));

this.on('newListener', (name, listener) => {
if (name === RESTEvents.Request || name === RESTEvents.Response) this.requestManager.on(name, listener);
Expand Down
107 changes: 102 additions & 5 deletions packages/rest/src/lib/RequestManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { RequestInit, BodyInit } from 'node-fetch';
import type { IHandler } from './handlers/IHandler';
import { SequentialHandler } from './handlers/SequentialHandler';
import type { RESTOptions, RestEvents } from './REST';
import { DefaultRestOptions, DefaultUserAgent } from './utils/constants';
import { DefaultRestOptions, DefaultUserAgent, RESTEvents } from './utils/constants';

let agent: Agent | null = null;

Expand Down Expand Up @@ -125,6 +125,16 @@ export interface RouteData {
original: RouteLike;
}

/**
* Represents a hash and its associated fields
*
* @internal
*/
export interface HashData {
value: string;
lastAccess: number;
}

export interface RequestManager {
on: (<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void) => this) &
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
Expand Down Expand Up @@ -164,7 +174,7 @@ export class RequestManager extends EventEmitter {
/**
* API bucket hashes that are cached from provided routes
*/
public readonly hashes = new Collection<string, string>();
public readonly hashes = new Collection<string, HashData>();

/**
* Request handlers created from the bucket hash and the major parameters
Expand All @@ -174,13 +184,83 @@ export class RequestManager extends EventEmitter {
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
#token: string | null = null;

private hashTimer!: NodeJS.Timer;
private handlerTimer!: NodeJS.Timer;

public readonly options: RESTOptions;

public constructor(options: Partial<RESTOptions>) {
super();
this.options = { ...DefaultRestOptions, ...options };
this.options.offset = Math.max(0, this.options.offset);
this.globalRemaining = this.options.globalRequestsPerSecond;

// Start sweepers
this.setupSweepers();
}

private setupSweepers() {
const validateMaxInterval = (interval: number) => {
if (interval > 14_400_000) {
throw new Error('Cannot set an interval greater than 4 hours');
}
};

if (this.options.hashSweepInterval !== 0 && this.options.hashSweepInterval !== Infinity) {
validateMaxInterval(this.options.hashSweepInterval);
this.hashTimer = setInterval(() => {
// Only allocate a swept collection for event.
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved
const sweptHashes: Collection<string, HashData> = new Collection<string, HashData>();
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved

const currentDate = Date.now();

// Begin sweeping hash based on lifetimes
this.hashes.sweep((v, k) => {
// `-1` indicates a global hash
if (v.lastAccess === -1) return false;

// Check if lifetime has been exceeded
const shouldSweep = Math.floor(currentDate - v.lastAccess) > this.options.hashLifetime;

// Add hash to collection of swept hashes
if (shouldSweep) {
// Add to swept hashes
sweptHashes.set(k, v);
}

// Emit debug information
this.emit(RESTEvents.Debug, `Hash ${v.value} for ${k} swept due to lifetime being exceeded`);

return shouldSweep;
});

// Fire event
this.emit(RESTEvents.HashSweep, sweptHashes);
}, this.options.hashSweepInterval).unref();
}

if (this.options.handlerSweepInterval !== 0 && this.options.handlerSweepInterval !== Infinity) {
validateMaxInterval(this.options.handlerSweepInterval);
this.handlerTimer = setInterval(() => {
const sweptHandlers: Collection<string, IHandler> = new Collection<string, IHandler>();
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved

// Begin sweeping handlers based on activity
this.handlers.sweep((v, k) => {
const { inactive } = v;

// Collect inactive handlers
if (inactive) {
sweptHandlers.set(k, v);
}

this.emit(RESTEvents.Debug, `Handler ${v.id} for ${k} swept due to being inactive`);
return inactive;
});

// Fire event
this.emit(RESTEvents.HandlerSweep, sweptHandlers);
}, this.options.handlerSweepInterval).unref();
}
}

/**
Expand All @@ -201,12 +281,15 @@ export class RequestManager extends EventEmitter {
// Generalize the endpoint to its route data
const routeId = RequestManager.generateRouteData(request.fullRoute, request.method);
// Get the bucket hash for the generic route, or point to a global route otherwise
const hash =
this.hashes.get(`${request.method}:${routeId.bucketRoute}`) ?? `Global(${request.method}:${routeId.bucketRoute})`;
const hash = this.hashes.get(`${request.method}:${routeId.bucketRoute}`) ?? {
value: `Global(${request.method}:${routeId.bucketRoute})`,
lastAccess: -1,
};

// Get the request handler for the obtained hash, with its major parameter
const handler =
this.handlers.get(`${hash}:${routeId.majorParameter}`) ?? this.createHandler(hash, routeId.majorParameter);
this.handlers.get(`${hash.value}:${routeId.majorParameter}`) ??
this.createHandler(hash.value, routeId.majorParameter);

// Resolve the request into usable fetch/node-fetch options
const { url, fetchOptions } = this.resolveRequest(request);
Expand Down Expand Up @@ -323,6 +406,20 @@ export class RequestManager extends EventEmitter {
return { url, fetchOptions };
}

/**
* Stops the hash sweeping interval
*/
public clearHashSweeper() {
return clearInterval(this.hashTimer);
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Stops the request handler sweeping interval
*/
public clearHandlerSweeper() {
return clearInterval(this.handlerTimer);
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Generates route data for an endpoint:method
* @param endpoint The raw endpoint to generalize
Expand Down
2 changes: 2 additions & 0 deletions packages/rest/src/lib/handlers/IHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ export interface IHandler {
options: RequestInit,
bodyData: Pick<InternalRequest, 'files' | 'body'>,
) => Promise<unknown>;
readonly inactive: boolean;
readonly id: string;
}
11 changes: 10 additions & 1 deletion packages/rest/src/lib/handlers/SequentialHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,16 @@ export class SequentialHandler {
// Let library users know when rate limit buckets have been updated
this.debug(['Received bucket hash update', ` Old Hash : ${this.hash}`, ` New Hash : ${hash}`].join('\n'));
// This queue will eventually be eliminated via attrition
this.manager.hashes.set(`${method}:${routeId.bucketRoute}`, hash);
this.manager.hashes.set(`${method}:${routeId.bucketRoute}`, { value: hash, lastAccess: Date.now() });
} else if (hash) {
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved
// Handle the case where hash value doesn't change
// Fetch the hash data from the manager
const hashData = this.manager.hashes.get(`${method}:${routeId.bucketRoute}`);

// When fetched, update the last access of the hash
if (hashData) {
hashData.lastAccess = Date.now();
}
}

// Handle retryAfter, which means we have actually hit a rate limit
Expand Down
5 changes: 5 additions & 0 deletions packages/rest/src/lib/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const DefaultRestOptions: Required<RESTOptions> = {
timeout: 15_000,
userAgentAppendix: `Node.js ${process.version}`,
version: APIVersion,
hashSweepInterval: 14_400_000, // 4 Hours
hashLifetime: 86_400_000, // 24 Hours
handlerSweepInterval: 3_600_000, // 1 Hour
};

/**
Expand All @@ -30,6 +33,8 @@ export const enum RESTEvents {
RateLimited = 'rateLimited',
Request = 'request',
Response = 'response',
HashSweep = 'hashSweep',
HandlerSweep = 'handlerSweep',
}

export const ALLOWED_EXTENSIONS = ['webp', 'png', 'jpg', 'jpeg', 'gif'] as const;
Expand Down