connect-qos
Version:
Connect middleware that helps maintain a high quality of service during heavy traffic
287 lines (243 loc) • 8.32 kB
text/typescript
import LRU from 'lru-cache';
export type MetricsOptions = {
historySize?: number;
maxAge?: number;
minHostRate?: number;
maxHostRate?: number;
maxHostRatio?: number;
minIpRate?: number;
maxIpRate?: number;
maxIpRateHostViolation?: number;
hostWhitelist?: Set<string>;
ipWhitelist?: Set<string>;
}
export enum ActorStatus {
Good = 200,
Whitelisted = 300,
Bad = 400
}
export enum BadActorType {
badHost = 'badHost',
hostViolation = 'hostViolation',
badIp = 'badIp',
userLag = 'userLag'
}
export type CacheItem = {
id: string;
history: Array<number>; // time
rate: number;
}
export const DEFAULT_HISTORY_SIZE: number = 200; // 0.5% hit rate enough to reside in LRU
export const DEFAULT_MAX_AGE: number = 1000 * 10; // 10s is generally more than sufficient history
export const DEFAULT_MIN_HOST_RATE: number = 20;
export const DEFAULT_MAX_HOST_RATE: number = 40;
export const DEFAULT_MAX_HOST_RATIO: number = 0; // disabled
export const DEFAULT_MIN_IP_RATE: number = 0; // disabled
export const DEFAULT_MAX_IP_RATE: number = 0; // disabled
export const DEFAULT_MAX_IP_RATE_BUSY_HOST: number = 0; // disabled
export const DEFAULT_HOST_WHITELIST = ['localhost'];
export const DEFAULT_IP_WHITELIST = [];
export class Metrics {
constructor(opts?: MetricsOptions) {
const {
historySize = DEFAULT_HISTORY_SIZE,
maxAge = DEFAULT_MAX_AGE,
minHostRate = DEFAULT_MIN_HOST_RATE,
maxHostRate = DEFAULT_MAX_HOST_RATE,
maxHostRatio = DEFAULT_MAX_HOST_RATIO,
minIpRate = DEFAULT_MIN_IP_RATE,
maxIpRate = DEFAULT_MAX_IP_RATE,
maxIpRateHostViolation = DEFAULT_MAX_IP_RATE_BUSY_HOST,
hostWhitelist = new Set(DEFAULT_HOST_WHITELIST),
ipWhitelist = new Set(DEFAULT_IP_WHITELIST)
} = (opts || {} as MetricsOptions);
if (minHostRate > maxHostRate) throw new Error(`${minHostRate} minHostRate cannot exceed ${maxHostRate} maxHostRate`)
if (minIpRate > maxIpRate) throw new Error(`${minIpRate} minIpRate cannot exceed ${maxIpRate} maxIpRate`)
const lruOptions: LRU.Options<string, CacheItem> = {
max: historySize,
//ttl: maxAge, // do NOT use ttl due to performance and we handle stale purges
allowStale: false,
updateAgeOnGet: true,
updateAgeOnHas: false
};
this.#hosts = new LRU(lruOptions);
this.#ips = new LRU(lruOptions);
this.#historySize = historySize;
this.#maxAge = maxAge;
this.#minHostRate = minHostRate;
this.#maxHostRate = maxHostRate;
this.#minHostRequests = Math.ceil(minHostRate * (maxAge/1000));
this.#maxHostRatio = Math.max(Math.min(maxHostRatio, 0.9), 0); // 90% is very high, but this cap is just to prevent invalid ratios
this.#hostRatioMaxCount = Math.ceil(this.#maxHostRatio * 100 * 10); // 10x requests compared to host ratio (10% * 10 = 100)
this.#minIpRate = minIpRate;
this.#maxIpRate = maxIpRate;
this.#maxIpRateHostViolation = maxIpRateHostViolation;
this.#minIpRequests = Math.round(minIpRate * (maxAge/1000));
this.#hostWhitelist = hostWhitelist;
this.#ipWhitelist = ipWhitelist;
}
#hosts: LRU<string, CacheItem>;
#ips: LRU<string, CacheItem>;
#historySize: number;
#maxAge: number;
#minHostRate: number;
#maxHostRate: number;
#minHostRequests: number;
#maxHostRatio: number;
#hostRatioMaxCount: number;
#hostRatioRequestHistory: Array<number> = [];
#hostRatioViolations = new Set<string>();
#hostRatioCounts = new Map<string, number>();
#minIpRate: number;
#maxIpRate: number;
#maxIpRateHostViolation: number;
#minIpRequests: number;
#hostWhitelist: Set<string>;
#ipWhitelist: Set<string>;
get hosts(): LRU<string, CacheItem> {
return this.#hosts;
}
get ips(): LRU<string, CacheItem> {
return this.#ips;
}
get minHostRate(): number {
return this.#minHostRate;
}
get maxHostRate(): number {
return this.#maxHostRate;
}
get maxHostRatio(): number {
return this.#maxHostRatio;
}
get hostRatioViolations(): Set<string> {
return this.#hostRatioViolations;
}
get minIpRate(): number {
return this.#minIpRate;
}
get maxIpRate(): number {
return this.#maxIpRate;
}
get maxIpRateHostViolation(): number {
return this.#maxIpRateHostViolation;
}
get historySize(): number {
return this.#historySize;
}
get maxAge(): number {
return this.#maxAge;
}
get hostWhitelist(): Set<string> {
return this.#hostWhitelist;
}
get ipWhitelist(): Set<string> {
return this.#ipWhitelist;
}
getHostInfo(source: string): ActorStatus|CacheItem|undefined {
return getInfo(source, {
lru: this.#hosts,
whitelist: this.#hostWhitelist,
minRequests: this.#minHostRequests,
maxAge: this.#maxAge
});
}
trackHost(source: string, cache?: CacheItem): CacheItem|undefined {
if (this.#maxHostRatio) {
// only track if ratio limits enabled
if (!this.#hostWhitelist.has(source)) {
this.#hostRatioCounts.set(source, (this.#hostRatioCounts.get(source) || 0) + 1);
}
this.#hostRatioRequestHistory = this.#hostRatioRequestHistory
.concat(Date.now())
.filter((ts) => ts >= Date.now() - 60000); // 1 minute history
if (this.#hostRatioRequestHistory.length >= this.#hostRatioMaxCount) {
// check for violations once we have sufficient history
const maxCount = Math.round(this.#hostRatioRequestHistory.length * this.#maxHostRatio);
this.#hostRatioViolations = [...this.#hostRatioCounts.entries()]
.reduce((violations, [source, count]) => {
if (count > maxCount) violations.add(source);
return violations;
}, new Set<string>());
this.#hostRatioCounts.clear();
this.#hostRatioRequestHistory = [];
}
}
return track(source, {
lru: this.#hosts,
cache,
minRate: this.#minHostRate
});
}
getIpInfo(source: string): ActorStatus|CacheItem|undefined {
return getInfo(source, {
lru: this.#ips,
whitelist: this.#ipWhitelist,
minRequests: this.#minIpRequests,
maxAge: this.#maxAge
});
}
trackIp(source: string, cache?: CacheItem): CacheItem|undefined {
return track(source, {
lru: this.#ips,
cache,
minRate: this.#minIpRate
});
}
}
export type GetInfoOptions = {
lru: LRU<string, CacheItem>,
whitelist: Set<string>,
minRequests: number,
maxAge: number
}
function getInfo(source: string, {
lru,
whitelist,
minRequests,
maxAge
}: GetInfoOptions): ActorStatus|CacheItem|undefined {
if (!minRequests) return ActorStatus.Good; // if monitoring is disabled treat as Good
// reserved to indicate will never be a bad actor
if (whitelist.has(source)) return ActorStatus.Whitelisted;
let cache: CacheItem|undefined = lru.get(source);
if (cache) { // always precompute `rate` & `ratio` based on NOW
// update rate
const now = Date.now();
const expiredAt = now - maxAge;
// always remove stale history before calculating rate
let expiredCount;
// slightly faster than shifting
for (expiredCount = 0; expiredCount < cache.history.length; expiredCount++) {
if (cache.history[expiredCount] >= expiredAt) break;
}
if (expiredCount) {
cache.history = cache.history.slice(expiredCount);
}
if (cache.history.length < minRequests) {
cache.rate = 0; // insufficient history to measure
} else {
const eldest = cache.history[0];
// default to 1ms to avoid divide by zero errors since we do have adequate history
const age = (now - eldest) || 1;
cache.rate = ((cache.history.length / age) * 1000);
}
}
return cache;
}
export type TrackOptions = {
lru: LRU<string, CacheItem>,
cache?: CacheItem,
minRate: number
}
function track(source: string, options: TrackOptions): CacheItem|undefined {
const { lru, minRate } = options;
let { cache } = options;
if (!minRate) return void 0; // tracking disabled
if (!cache) cache = lru.get(source); // if not supplied grab from lru
if (!cache) { // if not in LRU create it
cache = { id: source, history: new Array(), rate: 0 };
lru.set(source, cache);
}
cache.history.push(Date.now()); // head=eldest, tail=newest
return cache;
}