Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -4,4 +4,6 @@ import { tuneConnection } from '../db/connection.js';
|
||||
|
||||
const handle = new Database(workerData.dbPath, { readonly: true, fileMustExist: true });
|
||||
tuneConnection(handle, true);
|
||||
export function db() { return handle; }
|
||||
export function db() {
|
||||
return handle;
|
||||
}
|
||||
|
||||
@@ -3,43 +3,78 @@ 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 {
|
||||
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, { resolve: (value: any) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
|
||||
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();
|
||||
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 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.'));
|
||||
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) } });
|
||||
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);
|
||||
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();
|
||||
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.')));
|
||||
@@ -49,12 +84,20 @@ class ReportWorker {
|
||||
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();
|
||||
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.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;
|
||||
}
|
||||
@@ -62,12 +105,24 @@ class ReportWorker {
|
||||
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 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() });
|
||||
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()]); }
|
||||
export async function closeReports() {
|
||||
await Promise.all([metrics.close(), leaderboard.close(), capacity.close()]);
|
||||
}
|
||||
|
||||
@@ -3,23 +3,39 @@ 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 table of ['metric_samples', 'servers']) 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}
|
||||
for (const table of ['metric_samples', 'servers'])
|
||||
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='metrics'; END;`);
|
||||
}
|
||||
if (handle.prepare("SELECT 1 FROM settings WHERE key='completed_report_revisions_v1'").get()) return;
|
||||
}
|
||||
if (handle.prepare("SELECT 1 FROM settings WHERE key='completed_report_revisions_v1'").get())
|
||||
return;
|
||||
handle.transaction(() => {
|
||||
for (const table of ['leaderboard_matches', 'leaderboard_match_players', 'leaderboard_players']) {
|
||||
for (const table of [
|
||||
'leaderboard_matches',
|
||||
'leaderboard_match_players',
|
||||
'leaderboard_players'
|
||||
]) {
|
||||
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
|
||||
const name = `report_${table}_${action}`;
|
||||
const versions = action === 'INSERT' ? ['NEW'] : action === 'DELETE' ? ['OLD'] : ['OLD', 'NEW'];
|
||||
const condition = table === 'leaderboard_matches'
|
||||
? `WHEN ${versions.map(row => `${row}.status='completed'`).join(' OR ')}`
|
||||
: table === 'leaderboard_match_players'
|
||||
? `WHEN ${versions.map(row => `(SELECT status FROM leaderboard_matches WHERE id=${row}.match_id)='completed'`).join(' OR ')}`
|
||||
: action === 'UPDATE' ? 'WHEN OLD.name IS NOT NEW.name' : '';
|
||||
const event = table === 'leaderboard_players' && action === 'UPDATE' ? 'UPDATE OF name' : action;
|
||||
const versions =
|
||||
action === 'INSERT' ? ['NEW'] : action === 'DELETE' ? ['OLD'] : ['OLD', 'NEW'];
|
||||
const condition =
|
||||
table === 'leaderboard_matches'
|
||||
? `WHEN ${versions.map((row) => `${row}.status='completed'`).join(' OR ')}`
|
||||
: table === 'leaderboard_match_players'
|
||||
? `WHEN ${versions.map((row) => `(SELECT status FROM leaderboard_matches WHERE id=${row}.match_id)='completed'`).join(' OR ')}`
|
||||
: action === 'UPDATE'
|
||||
? 'WHEN OLD.name IS NOT NEW.name'
|
||||
: '';
|
||||
const event =
|
||||
table === 'leaderboard_players' && action === 'UPDATE'
|
||||
? 'UPDATE OF name'
|
||||
: action;
|
||||
handle.exec(`DROP TRIGGER IF EXISTS ${name};
|
||||
CREATE TRIGGER ${name} AFTER ${event} ON ${table} ${condition}
|
||||
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='leaderboard'; END;`);
|
||||
|
||||
@@ -15,26 +15,56 @@ parentPort!.on('message', ({ id, kind, input }) => {
|
||||
// Capacity refreshes every 30 seconds without adding write triggers
|
||||
// to the heartbeat path. It also has its own worker, so large
|
||||
// history ranges cannot queue ahead of fleet or player reports.
|
||||
const revision = kind === 'capacity' ? 0
|
||||
: (db().prepare('SELECT revision FROM report_revisions WHERE topic=?').get(topic) as { revision: number }).revision;
|
||||
const revision =
|
||||
kind === 'capacity'
|
||||
? 0
|
||||
: (
|
||||
db()
|
||||
.prepare('SELECT revision FROM report_revisions WHERE topic=?')
|
||||
.get(topic) as { revision: number }
|
||||
).revision;
|
||||
const timed = kind === 'metrics' || kind === 'capacity';
|
||||
const now = timed ? input.now : Date.now();
|
||||
const key = JSON.stringify([kind, revision, timed ? Math.floor(now / 30_000) : 0, timed ? input.query : input]);
|
||||
const key = JSON.stringify([
|
||||
kind,
|
||||
revision,
|
||||
timed ? Math.floor(now / 30_000) : 0,
|
||||
timed ? 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 === 'capacity' ? readCapacityHistory(input.query, now)
|
||||
: kind === 'leaderboard' ? getLeaderboard(input)
|
||||
: { profile: getPlayerProfile(input.guid, input.offset, input.options), entry: getPlayerLeaderboardEntry(input.guid, input.options) };
|
||||
if (saved) {
|
||||
cache.delete(key);
|
||||
cache.set(key, saved);
|
||||
return { value: saved.value, cached: true };
|
||||
}
|
||||
const value =
|
||||
kind === 'metrics'
|
||||
? readMetrics(input.query, now)
|
||||
: kind === 'capacity'
|
||||
? readCapacityHistory(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;
|
||||
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);
|
||||
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' }); }
|
||||
} catch (cause) {
|
||||
parentPort!.postMessage({
|
||||
id,
|
||||
error: cause instanceof Error ? cause.message : 'Report failed'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user