129 lines
5.4 KiB
TypeScript
129 lines
5.4 KiB
TypeScript
import { Worker } from 'node:worker_threads';
|
|
import { resolve } from 'node:path';
|
|
import { config } from '../config.js';
|
|
import { db } from '../db/index.js';
|
|
import type { MetricsQuery, MetricsData } from '../../metrics.js';
|
|
import type {
|
|
LeaderboardOptions,
|
|
LeaderboardResponse,
|
|
PlayerProfile,
|
|
LeaderboardEntry
|
|
} from '../../leaderboard.js';
|
|
import type { CapacityHistory, CapacityQuery } from '../../capacity.js';
|
|
import { ensureHostSchema } from '../hosts/schema.js';
|
|
|
|
class ReportWorker {
|
|
private worker: Worker | null = null;
|
|
private sequence = 0;
|
|
private pending = new Map<
|
|
number,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Pending requests hold resolvers for different report result types.
|
|
{ resolve: (value: any) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }
|
|
>();
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each request key identifies a caller-specific report result type.
|
|
private flights = new Map<string, Promise<any>>();
|
|
async close() {
|
|
const worker = this.worker;
|
|
this.worker = null;
|
|
for (const pending of this.pending.values()) {
|
|
clearTimeout(pending.timer);
|
|
pending.reject(new Error('Report service stopped'));
|
|
}
|
|
this.pending.clear();
|
|
this.flights.clear();
|
|
await worker?.terminate();
|
|
}
|
|
run<T>(kind: string, input: unknown): Promise<T> {
|
|
const requestInput = input as { query?: unknown; now?: number };
|
|
const timed = kind === 'metrics' || kind === 'capacity';
|
|
const key = JSON.stringify(
|
|
timed
|
|
? [kind, requestInput.query, Math.floor(requestInput.now! / 30_000)]
|
|
: [kind, input]
|
|
);
|
|
const flight = this.flights.get(key);
|
|
if (flight) return flight;
|
|
if (this.pending.size >= 64)
|
|
return Promise.reject(new Error('Reports are busy. Please retry shortly.'));
|
|
db(); // Finish migrations before opening the independent reader.
|
|
if (!this.worker) {
|
|
const worker = (this.worker = new Worker(
|
|
resolve(
|
|
process.env.NODE_ENV === 'production'
|
|
? 'build/reports/worker.mjs'
|
|
: '.svelte-kit/reports/worker.mjs'
|
|
),
|
|
{ workerData: { dbPath: resolve(config.dbPath) } }
|
|
));
|
|
worker.on('message', ({ id, value, error, elapsedMs, cached }) => {
|
|
const pending = this.pending.get(id);
|
|
if (!pending) return;
|
|
clearTimeout(pending.timer);
|
|
this.pending.delete(id);
|
|
if (process.env.EC_PERF_LOG === 'true')
|
|
console.info('[report]', { kind, elapsedMs, cached });
|
|
if (error) pending.reject(new Error(error));
|
|
else pending.resolve(value);
|
|
if (!this.pending.size) worker.unref();
|
|
});
|
|
const fail = (error: Error) => {
|
|
if (this.worker !== worker) return;
|
|
this.worker = null;
|
|
for (const pending of this.pending.values()) {
|
|
clearTimeout(pending.timer);
|
|
pending.reject(error);
|
|
}
|
|
this.pending.clear();
|
|
void worker.terminate();
|
|
};
|
|
worker.on('error', fail);
|
|
worker.on('exit', () => fail(new Error('Report worker stopped. Please retry.')));
|
|
}
|
|
this.worker.ref();
|
|
const id = ++this.sequence;
|
|
const promise = new Promise<T>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
// Reset the worker rather than leaving an expensive abandoned query queued.
|
|
const worker = this.worker;
|
|
this.worker = null;
|
|
for (const item of this.pending.values()) {
|
|
clearTimeout(item.timer);
|
|
item.reject(new Error('Report timed out. Please retry.'));
|
|
}
|
|
this.pending.clear();
|
|
void worker?.terminate();
|
|
}, 60_000);
|
|
this.pending.set(id, { resolve, reject, timer });
|
|
this.worker!.postMessage({ id, kind, input });
|
|
}).finally(() => {
|
|
if (this.flights.get(key) === promise) this.flights.delete(key);
|
|
});
|
|
this.flights.set(key, promise);
|
|
return promise;
|
|
}
|
|
}
|
|
const metrics = new ReportWorker();
|
|
const leaderboard = new ReportWorker();
|
|
const capacity = new ReportWorker();
|
|
export const metricsReport = (query: MetricsQuery) =>
|
|
metrics.run<MetricsData>('metrics', { query, now: Date.now() });
|
|
export const leaderboardReport = (query: LeaderboardOptions) =>
|
|
leaderboard.run<LeaderboardResponse>('leaderboard', query);
|
|
export const playerReport = (guid: string, offset: number, options: LeaderboardOptions) =>
|
|
leaderboard.run<{ profile: PlayerProfile | null; entry: LeaderboardEntry | null }>('player', {
|
|
guid,
|
|
offset,
|
|
options
|
|
});
|
|
export const capacityHistoryReport = (query: CapacityQuery) => {
|
|
ensureHostSchema(db());
|
|
// Game changes current placement estimates, not measured host resources.
|
|
return capacity.run<CapacityHistory>('capacity', {
|
|
query: { ...query, game: 'all' },
|
|
now: Date.now()
|
|
});
|
|
};
|
|
export async function closeReports() {
|
|
await Promise.all([metrics.close(), leaderboard.close(), capacity.close()]);
|
|
}
|