Improve application responsiveness and background processing
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { workerData } from 'node:worker_threads';
|
||||
|
||||
const handle = new Database(workerData.dbPath, { readonly: true, fileMustExist: true });
|
||||
export function db() { return handle; }
|
||||
@@ -0,0 +1,64 @@
|
||||
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';
|
||||
|
||||
class ReportWorker {
|
||||
private worker: Worker | null = null;
|
||||
private sequence = 0;
|
||||
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
|
||||
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?: MetricsQuery; now?: number };
|
||||
const key = JSON.stringify(kind === 'metrics' ? [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();
|
||||
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 async function closeReports() { await Promise.all([metrics.close(), leaderboard.close()]); }
|
||||
@@ -0,0 +1,14 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export function installReportRevisions(handle: Database.Database) {
|
||||
handle.exec(`CREATE TABLE IF NOT EXISTS report_revisions (topic TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0);
|
||||
INSERT OR IGNORE INTO report_revisions(topic) VALUES ('metrics'), ('leaderboard');`);
|
||||
for (const [topic, tables] of Object.entries({ metrics: ['metric_samples', 'servers'],
|
||||
leaderboard: ['leaderboard_matches', 'leaderboard_match_players', 'leaderboard_players'] })) {
|
||||
for (const table of tables) for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
|
||||
const condition = table === 'servers' && action === 'UPDATE' ? 'WHEN OLD.destroyed_at IS NOT NEW.destroyed_at OR OLD.name IS NOT NEW.name' : '';
|
||||
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_${table}_${action} AFTER ${action} ON ${table} ${condition}
|
||||
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='${topic}'; END;`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
import { db } from '../db/index.js';
|
||||
import { readMetrics } from '../fleet/metrics.js';
|
||||
import { getLeaderboard, getPlayerLeaderboardEntry } from '../fleet/leaderboard.js';
|
||||
import { getPlayerProfile } from '../fleet/player-profile.js';
|
||||
|
||||
const cache = new Map<string, { value: unknown; bytes: number }>();
|
||||
let bytes = 0;
|
||||
parentPort!.on('message', ({ id, kind, input }) => {
|
||||
const started = performance.now();
|
||||
try {
|
||||
const result = db().transaction(() => {
|
||||
const topic = kind === 'metrics' ? 'metrics' : 'leaderboard';
|
||||
const { revision } = db().prepare('SELECT revision FROM report_revisions WHERE topic=?').get(topic) as { revision: number };
|
||||
const now = kind === 'metrics' ? input.now : Date.now();
|
||||
const key = JSON.stringify([kind, revision, kind === 'metrics' ? Math.floor(now / 30_000) : 0, kind === 'metrics' ? input.query : input]);
|
||||
const saved = cache.get(key);
|
||||
if (saved) { cache.delete(key); cache.set(key, saved); return { value: saved.value, cached: true }; }
|
||||
const value = kind === 'metrics' ? readMetrics(input.query, now)
|
||||
: kind === 'leaderboard' ? getLeaderboard(input)
|
||||
: { profile: getPlayerProfile(input.guid, input.offset, input.options), entry: getPlayerLeaderboardEntry(input.guid, input.options) };
|
||||
const size = Buffer.byteLength(JSON.stringify(value));
|
||||
if (size < 16 * 1024 * 1024) {
|
||||
cache.set(key, { value, bytes: size }); bytes += size;
|
||||
while (cache.size > 64 || bytes > 32 * 1024 * 1024) {
|
||||
const oldest = cache.keys().next().value!; bytes -= cache.get(oldest)!.bytes; cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
return { value, cached: false };
|
||||
})();
|
||||
parentPort!.postMessage({ id, ...result, elapsedMs: performance.now() - started });
|
||||
} catch (cause) { parentPort!.postMessage({ id, error: cause instanceof Error ? cause.message : 'Report failed' }); }
|
||||
});
|
||||
Reference in New Issue
Block a user