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
16 changes: 15 additions & 1 deletion packages/rest/src/lib/REST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ 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 '../index';
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved
import type Collection from '@discordjs/collection';

/**
* Options to be passed when creating the REST instance
Expand Down Expand Up @@ -74,6 +76,16 @@ export interface RESTOptions {
* @default '9'
*/
version: string;
/**
* The amount of time in milliseconds that passes between each hash sweep
* @default 3_600_000
*/
hashSweepInterval: number;
/**
* The maximum amount of time a hash can exist in milliseconds without being hit with a request
* @default 21_600_000
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved
*/
hashLifetime: number;
}

/**
Expand Down Expand Up @@ -168,6 +180,7 @@ 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>];
}

export interface REST {
Expand Down Expand Up @@ -197,7 +210,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
61 changes: 56 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 @@ -181,6 +191,44 @@ export class RequestManager extends EventEmitter {
this.options = { ...DefaultRestOptions, ...options };
this.options.offset = Math.max(0, this.options.offset);
this.globalRemaining = this.options.globalRequestsPerSecond;

setInterval(() => {
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved
// Only allocate a swept collection if there are listeners
let sweptHashes: Collection<string, HashData> | null = null;
const isListeningToSweeps = this.listenerCount(RESTEvents.HashSweep) > 0;
if (isListeningToSweeps) {
sweptHashes = new Collection<string, HashData>();
}
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved

const listeningToDebug = this.listenerCount(RESTEvents.Debug) > 0;
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved
const curDate = Date.now();

// Begin sweep 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(curDate - v.lastAccess) > this.options.hashLifetime;

// Add hash to collection of swept hashes
if (shouldSweep && isListeningToSweeps) {
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved
// Add to swept hashes
sweptHashes!.set(k, v);
}

if (listeningToDebug) {
this.emit(RESTEvents.Debug, `Hash ${v.value} for ${k} swept due to lifetime being exceeded`);
}
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved

return shouldSweep;
});

// Fire event
if (isListeningToSweeps) {
this.emit(RESTEvents.HashSweep, sweptHashes ?? new Collection<string, HashData>());
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved
}
}, this.options.hashSweepInterval).unref();
}

/**
Expand All @@ -201,12 +249,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
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
3 changes: 3 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,8 @@ export const DefaultRestOptions: Required<RESTOptions> = {
timeout: 15_000,
userAgentAppendix: `Node.js ${process.version}`,
version: APIVersion,
hashSweepInterval: 3_600_000,
hashLifetime: 21_600_000,
};

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

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