Add autoscaling outcomes to Metrics (Refs #140)
This commit is contained in:
@@ -8,6 +8,7 @@ import { installPerformanceIndexes } from './performance-indexes.js';
|
||||
import {
|
||||
installMetricsMatchRevisions,
|
||||
installMetricsPolicyRevisions,
|
||||
installMetricsProvisioningRevisions,
|
||||
installReportRevisions
|
||||
} from '../reports/revisions.js';
|
||||
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
|
||||
@@ -79,6 +80,11 @@ export const migrations: readonly Migration[] = [
|
||||
version: 14,
|
||||
name: 'metrics match report revisions',
|
||||
run: installMetricsMatchRevisions
|
||||
},
|
||||
{
|
||||
version: 15,
|
||||
name: 'metrics provisioning report revisions',
|
||||
run: installMetricsProvisioningRevisions
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { parseServerName, zombieMaps } from '../../map-aliases.js';
|
||||
import type { AutoscalingMetrics, MetricsScope } from '../../metrics.js';
|
||||
|
||||
/** Provisioning sources survive policy/decision deletion and server removal. */
|
||||
export function readAutoscalingMetrics(scope: MetricsScope, since: number, now: number) {
|
||||
// Group in SQLite to avoid transferring every operation. IN deduplicates source
|
||||
// links, so one operation always counts once. Keep older groups for scope options.
|
||||
const groups = db()
|
||||
.prepare(
|
||||
`WITH operations AS (
|
||||
SELECT server, action, state, finished_at >= ? AS in_window,
|
||||
ROUND((julianday(finished_at)-julianday(started_at))*86400000)/1000.0 AS seconds
|
||||
FROM server_deployments
|
||||
WHERE id IN (SELECT operation_id FROM provisioning_sources WHERE kind='autoscaling')
|
||||
AND finished_at <= ? AND state IN ('done','warning','failed','cancelled')
|
||||
), measured AS (
|
||||
SELECT *, CASE WHEN in_window AND action='create'
|
||||
AND state IN ('done','warning') AND seconds >= 0 THEN seconds END AS readiness
|
||||
FROM operations
|
||||
)
|
||||
SELECT server,
|
||||
SUM(in_window AND action='create' AND state IN ('done','warning')) AS added,
|
||||
SUM(in_window AND action='destroy' AND state IN ('done','warning')) AS removed,
|
||||
SUM(in_window AND state='failed') AS failed,
|
||||
SUM(in_window AND state='cancelled') AS cancelled,
|
||||
COUNT(readiness) AS readinessSamples,
|
||||
COALESCE(SUM(readiness),0) AS readinessSeconds,
|
||||
MAX(readiness) AS readinessMaxSeconds
|
||||
FROM measured GROUP BY server`
|
||||
)
|
||||
.all(new Date(since).toISOString(), new Date(now).toISOString()) as (Omit<
|
||||
AutoscalingMetrics,
|
||||
'readinessMeanSeconds'
|
||||
> & { server: string; readinessSeconds: number })[];
|
||||
const summary: AutoscalingMetrics = {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
readinessSamples: 0,
|
||||
readinessMeanSeconds: null,
|
||||
readinessMaxSeconds: null
|
||||
};
|
||||
const maps = new Map<string, { game: string; map: string }>();
|
||||
let readinessSeconds = 0;
|
||||
for (const group of groups) {
|
||||
// Autoscaling names encode the selected variant. Never use today's server
|
||||
// registration or the policy scope (which may cover every map in a game).
|
||||
const parsed = parseServerName(group.server);
|
||||
const catalog = zombieMaps(parsed?.game ?? null);
|
||||
const entry = catalog.find(
|
||||
(map) =>
|
||||
map.slug === parsed?.mapSlug ||
|
||||
map.friendly.toLowerCase().replace(/[^a-z0-9]/g, '') === parsed?.mapSlug
|
||||
);
|
||||
if (parsed && entry) {
|
||||
const pair = { game: parsed.game, map: entry.friendly };
|
||||
maps.set(JSON.stringify(pair), pair);
|
||||
}
|
||||
if (scope.game !== null && parsed?.game !== scope.game) continue;
|
||||
if (scope.map !== null) {
|
||||
const label = scope.map.toLowerCase();
|
||||
// A canonical T6 engine map can describe several variants; do not
|
||||
// silently combine Town, Farm, Bus Depot and TranZit for that label.
|
||||
const matches = catalog.filter(
|
||||
(map) =>
|
||||
map.friendly.toLowerCase() === label ||
|
||||
map.slug === label ||
|
||||
map.canonical === label
|
||||
);
|
||||
if (!entry || matches.length !== 1 || matches[0].slug !== entry.slug) continue;
|
||||
}
|
||||
summary.added += group.added;
|
||||
summary.removed += group.removed;
|
||||
summary.failed += group.failed;
|
||||
summary.cancelled += group.cancelled;
|
||||
summary.readinessSamples += group.readinessSamples;
|
||||
readinessSeconds += group.readinessSeconds;
|
||||
if (group.readinessMaxSeconds !== null)
|
||||
summary.readinessMaxSeconds = Math.max(
|
||||
summary.readinessMaxSeconds ?? 0,
|
||||
group.readinessMaxSeconds
|
||||
);
|
||||
}
|
||||
if (summary.readinessSamples > 0)
|
||||
summary.readinessMeanSeconds = readinessSeconds / summary.readinessSamples;
|
||||
return { summary, maps: [...maps.values()] };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { fleetSettings } from '../settings.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { ONLINE_MAX_MS } from '../../server-freshness.js';
|
||||
import { zombieMaps } from '../../map-aliases.js';
|
||||
import { readAutoscalingMetrics } from './metrics-autoscaling.js';
|
||||
import {
|
||||
parseMetricsQuery,
|
||||
type HeatmapCell,
|
||||
@@ -207,10 +208,11 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
`SELECT DISTINCT game, map_name AS map FROM leaderboard_matches WHERE started_at <= ?`
|
||||
)
|
||||
.all(new Date(now).toISOString()) as { game: string; map: string }[];
|
||||
const autoscaling = readAutoscalingMetrics(scope, since, now);
|
||||
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const availableMaps = [
|
||||
...new Map(
|
||||
[...groups, ...matchMaps].map(({ game, map }) => [
|
||||
[...groups, ...matchMaps, ...autoscaling.maps].map(({ game, map }) => [
|
||||
JSON.stringify([game, map]),
|
||||
{ game, map }
|
||||
])
|
||||
@@ -514,6 +516,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
summary,
|
||||
running,
|
||||
matches: matchMetrics(totalMatches),
|
||||
autoscaling: autoscaling.summary,
|
||||
sampledServerCount: sampledServers.size,
|
||||
maps,
|
||||
points,
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/** Provisioning outcomes remain report inputs even after the policy is removed. */
|
||||
export function installMetricsProvisioningRevisions(handle: Database.Database) {
|
||||
for (const table of ['server_deployments', 'provisioning_sources'])
|
||||
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
|
||||
const versions =
|
||||
action === 'INSERT' ? ['NEW'] : action === 'DELETE' ? ['OLD'] : ['OLD', 'NEW'];
|
||||
const fields = ['server', 'action', 'state', 'started_at', 'finished_at'];
|
||||
const condition =
|
||||
table === 'provisioning_sources'
|
||||
? versions.map((row) => `${row}.kind='autoscaling'`).join(' OR ')
|
||||
: `EXISTS (SELECT 1 FROM provisioning_sources WHERE operation_id=${action === 'DELETE' ? 'OLD' : 'NEW'}.id AND kind='autoscaling')${
|
||||
action === 'UPDATE'
|
||||
? ` AND (${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} WHEN ${condition}
|
||||
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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'])
|
||||
|
||||
Reference in New Issue
Block a user