This commit is contained in:
@@ -5,7 +5,11 @@ import { ensureAutoscalingSchema } from '../autoscaling/schema.js';
|
||||
import { ensureRebalancingSchema } from '../rebalancing/schema.js';
|
||||
import { migrateFileBlobs } from './file-blobs.js';
|
||||
import { installPerformanceIndexes } from './performance-indexes.js';
|
||||
import { installMetricsPolicyRevisions, installReportRevisions } from '../reports/revisions.js';
|
||||
import {
|
||||
installMetricsMatchRevisions,
|
||||
installMetricsPolicyRevisions,
|
||||
installReportRevisions
|
||||
} from '../reports/revisions.js';
|
||||
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
|
||||
import { migrateHostSchema } from '../hosts/schema.js';
|
||||
import { migrateAuthSchema } from '../auth/schema.js';
|
||||
@@ -70,6 +74,11 @@ export const migrations: readonly Migration[] = [
|
||||
version: 13,
|
||||
name: 'metrics policy report revisions',
|
||||
run: installMetricsPolicyRevisions
|
||||
},
|
||||
{
|
||||
version: 14,
|
||||
name: 'metrics match report revisions',
|
||||
run: installMetricsMatchRevisions
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+110
-23
@@ -6,6 +6,7 @@ import {
|
||||
parseMetricsQuery,
|
||||
type HeatmapCell,
|
||||
type MapMetrics,
|
||||
type MatchMetrics,
|
||||
type MetricPoint,
|
||||
type MetricsData,
|
||||
type MetricsDays,
|
||||
@@ -47,6 +48,27 @@ function emptyRunning(): RunningMetrics {
|
||||
return { average: null, peak: 0, capturedHours: 0 };
|
||||
}
|
||||
|
||||
interface MatchTotals {
|
||||
started: number;
|
||||
withRecordedPlayers: number;
|
||||
participants: number;
|
||||
solo: number;
|
||||
}
|
||||
|
||||
function matchMetrics(totals: MatchTotals): MatchMetrics {
|
||||
return {
|
||||
started: totals.started,
|
||||
withRecordedPlayers: totals.withRecordedPlayers,
|
||||
averageGroupSize:
|
||||
totals.withRecordedPlayers > 0
|
||||
? totals.participants / totals.withRecordedPlayers
|
||||
: null,
|
||||
solo: totals.solo,
|
||||
soloShare:
|
||||
totals.withRecordedPlayers > 0 ? (100 * totals.solo) / totals.withRecordedPlayers : null
|
||||
};
|
||||
}
|
||||
|
||||
function addRunning(total: RunningMetrics, count: number, hours: number): void {
|
||||
if (hours <= 0) return;
|
||||
const lobbyHours = (total.average ?? 0) * total.capturedHours + count * hours;
|
||||
@@ -183,9 +205,25 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
scopeFirstSampleAt: scopedBounds.first,
|
||||
scopeLatestSampleAt: scopedBounds.latest
|
||||
};
|
||||
// Match history has independent retention and may outlive occupancy samples.
|
||||
// Preserve exact recorded labels, including distinct T6 variants.
|
||||
const matchMaps = handle
|
||||
.prepare(
|
||||
`SELECT DISTINCT game, map_name AS map FROM leaderboard_matches WHERE started_at <= ?`
|
||||
)
|
||||
.all(new Date(now).toISOString()) as { game: string; map: string }[];
|
||||
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const availableMaps = [
|
||||
...new Map(
|
||||
[...groups, ...matchMaps].map(({ game, map }) => [
|
||||
JSON.stringify([game, map]),
|
||||
{ game, map }
|
||||
])
|
||||
).values()
|
||||
].sort((a, b) => compareText(a.game, b.game) || compareText(a.map, b.map));
|
||||
const scopeOptions = {
|
||||
games: [...new Set(groups.map((group) => group.game))],
|
||||
maps: groups.map(({ game, map }) => ({ game, map }))
|
||||
games: [...new Set(availableMaps.map((group) => group.game))],
|
||||
maps: availableMaps
|
||||
};
|
||||
const summary = emptyUtilization();
|
||||
const running = emptyRunning();
|
||||
@@ -200,6 +238,74 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
};
|
||||
const serverTotals = new Map<string, ServerMetrics>();
|
||||
const mapTotals = new Map<string, Map<string, MapMetrics>>();
|
||||
function mapFor(gameName: string, label: string): MapMetrics {
|
||||
let game = mapTotals.get(gameName);
|
||||
if (!game) {
|
||||
game = new Map();
|
||||
mapTotals.set(gameName, game);
|
||||
}
|
||||
let map = game.get(label);
|
||||
if (!map) {
|
||||
map = {
|
||||
game: gameName,
|
||||
map: label,
|
||||
matches: matchMetrics({
|
||||
started: 0,
|
||||
withRecordedPlayers: 0,
|
||||
participants: 0,
|
||||
solo: 0
|
||||
}),
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 0,
|
||||
noEmptyAtMaximum: 0,
|
||||
noEmptyBelowMaximum: 0,
|
||||
policy: policyFor(gameName, label),
|
||||
...emptyUtilization()
|
||||
};
|
||||
game.set(label, map);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
const matchConditions = ['m.started_at >= ?', 'm.started_at <= ?'];
|
||||
const matchParams = [new Date(since).toISOString(), new Date(now).toISOString()];
|
||||
if (scope.game !== null) {
|
||||
matchConditions.push('m.game = ?');
|
||||
matchParams.push(scope.game);
|
||||
}
|
||||
if (scope.map !== null) {
|
||||
matchConditions.push('m.map_name = ?');
|
||||
matchParams.push(scope.map);
|
||||
}
|
||||
// Group participants per match before aggregating maps, so roster size never
|
||||
// multiplies the start count. The participant PK deduplicates returning players.
|
||||
const matchGroups = handle
|
||||
.prepare(
|
||||
`SELECT game, map, COUNT(*) AS started,
|
||||
SUM(players > 0) AS withRecordedPlayers,
|
||||
SUM(players) AS participants, SUM(players = 1) AS solo
|
||||
FROM (
|
||||
SELECT m.game, m.map_name AS map, COUNT(p.guid) AS players
|
||||
FROM leaderboard_matches m
|
||||
LEFT JOIN leaderboard_match_players p ON p.match_id = m.id
|
||||
WHERE ${matchConditions.join(' AND ')}
|
||||
GROUP BY m.id
|
||||
) GROUP BY game, map`
|
||||
)
|
||||
.all(...matchParams) as (MatchTotals & { game: string; map: string })[];
|
||||
const totalMatches: MatchTotals = {
|
||||
started: 0,
|
||||
withRecordedPlayers: 0,
|
||||
participants: 0,
|
||||
solo: 0
|
||||
};
|
||||
for (const group of matchGroups) {
|
||||
mapFor(group.game, group.map).matches = matchMetrics(group);
|
||||
totalMatches.started += group.started;
|
||||
totalMatches.withRecordedPlayers += group.withRecordedPlayers;
|
||||
totalMatches.participants += group.participants;
|
||||
totalMatches.solo += group.solo;
|
||||
}
|
||||
const points: MetricPoint[] = [];
|
||||
for (let at = Math.floor(since / bucketMs) * bucketMs; at < now; at += bucketMs) {
|
||||
const end = Math.min(at + bucketMs, now);
|
||||
@@ -327,26 +433,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
};
|
||||
serverTotals.set(sample.server, server);
|
||||
}
|
||||
let game = mapTotals.get(sample.game);
|
||||
if (!game) {
|
||||
game = new Map();
|
||||
mapTotals.set(sample.game, game);
|
||||
}
|
||||
let map = game.get(sample.map);
|
||||
if (!map) {
|
||||
map = {
|
||||
game: sample.game,
|
||||
map: sample.map,
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 0,
|
||||
noEmptyAtMaximum: 0,
|
||||
noEmptyBelowMaximum: 0,
|
||||
policy: policyFor(sample.game, sample.map),
|
||||
...emptyUtilization()
|
||||
};
|
||||
game.set(sample.map, map);
|
||||
}
|
||||
const map = mapFor(sample.game, sample.map);
|
||||
addObservation(summary, sample.players, hours);
|
||||
addObservation(server, sample.players, hours);
|
||||
addObservation(map, sample.players, hours);
|
||||
@@ -404,7 +491,6 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
}
|
||||
aggregateBatch(now);
|
||||
|
||||
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const servers = [...serverTotals.values()].sort(
|
||||
(a, b) => b.active - a.active || compareText(a.server, b.server)
|
||||
);
|
||||
@@ -442,6 +528,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
scopeOptions,
|
||||
summary,
|
||||
running,
|
||||
matches: matchMetrics(totalMatches),
|
||||
servers,
|
||||
maps,
|
||||
points,
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/** Match demand includes active and interrupted matches, independent of leaderboard eligibility. */
|
||||
export function installMetricsMatchRevisions(handle: Database.Database) {
|
||||
for (const table of ['leaderboard_matches', 'leaderboard_match_players'])
|
||||
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
|
||||
const fields =
|
||||
table === 'leaderboard_matches' ? ['game', 'map_name', 'started_at'] : ['match_id'];
|
||||
// Score/presence heartbeats do not change distinct participant counts.
|
||||
const condition =
|
||||
action === 'UPDATE'
|
||||
? `WHEN ${fields.map((field) => `OLD.${field} IS NOT NEW.${field}`).join(' OR ')}`
|
||||
: '';
|
||||
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_metrics_${table}_${action}
|
||||
AFTER ${action} ON ${table} ${condition}
|
||||
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Current policy settings are inputs to historical metrics advice. */
|
||||
export function installMetricsPolicyRevisions(handle: Database.Database) {
|
||||
for (const table of ['autoscaling_policies', 'settings'])
|
||||
|
||||
Reference in New Issue
Block a user