Add autoscaling outcomes to Metrics (Refs #140)
This commit is contained in:
@@ -131,6 +131,18 @@ export interface PooledAvailability {
|
||||
totalMaps: number;
|
||||
}
|
||||
|
||||
export interface AutoscalingMetrics {
|
||||
/** Retained Provisioning outcomes whose finish time is within the selected window. */
|
||||
added: number;
|
||||
removed: number;
|
||||
failed: number;
|
||||
cancelled: number;
|
||||
/** Successful creates with valid request/finish timestamps; includes queue time. */
|
||||
readinessSamples: number;
|
||||
readinessMeanSeconds: number | null;
|
||||
readinessMaxSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface MetricsData {
|
||||
days: MetricsDays;
|
||||
since: number;
|
||||
@@ -150,6 +162,7 @@ export interface MetricsData {
|
||||
/** Distinct recorded names sampled in this scope/window, including zero elapsed time. */
|
||||
sampledServerCount: number;
|
||||
matches: MatchMetrics;
|
||||
autoscaling: AutoscalingMetrics;
|
||||
maps: MapMetrics[];
|
||||
points: MetricPoint[];
|
||||
previous: PreviousMetrics;
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { AutoscalingMetrics } from '$lib/metrics.js';
|
||||
import { metricNumber } from '$ui/metrics.js';
|
||||
import { countLabel } from '$ui/plural.js';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Stat from '$ui/components/primitives/Stat.svelte';
|
||||
|
||||
let { metrics }: { metrics: AutoscalingMetrics } = $props();
|
||||
const outcomes = $derived(metrics.added + metrics.removed + metrics.failed + metrics.cancelled);
|
||||
const seconds = (value: number | null) => (value === null ? '—' : `${metricNumber(value)} s`);
|
||||
</script>
|
||||
|
||||
<Card as="section" ariaLabelledby="metrics-autoscaling-title">
|
||||
<header>
|
||||
<h2 id="metrics-autoscaling-title">Autoscaling</h2>
|
||||
<a href="/autoscaling">Open policies</a>
|
||||
</header>
|
||||
<div class="stats">
|
||||
<Stat label="Lobbies added" value={metrics.added} tween={false} />
|
||||
<Stat label="Lobbies removed" value={metrics.removed} tween={false} />
|
||||
<Stat label="Failed operations" value={metrics.failed} tween={false} />
|
||||
<Stat label="Cancelled operations" value={metrics.cancelled} tween={false} />
|
||||
<Stat label="Request to ready · average" value={seconds(metrics.readinessMeanSeconds)} />
|
||||
<Stat label="Request to ready · maximum" value={seconds(metrics.readinessMaxSeconds)} />
|
||||
</div>
|
||||
<p>
|
||||
{#if outcomes === 0}
|
||||
No completed, failed or cancelled autoscaling operations recorded in this period and
|
||||
scope.
|
||||
{:else}
|
||||
Autoscaling operations finished in this period and scope. Added and removed counts
|
||||
include successful operations only.
|
||||
{/if}
|
||||
{#if metrics.readinessSamples > 0}
|
||||
Readiness uses {countLabel(metrics.readinessSamples, 'completed create')} with recorded timing,
|
||||
including queue time.
|
||||
{:else}
|
||||
Request-to-ready time is unavailable without completed creates with recorded timing.
|
||||
{/if}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
a {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--space-5);
|
||||
}
|
||||
p {
|
||||
margin: var(--space-4) 0 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -24,6 +24,14 @@ const matches = (value: unknown) =>
|
||||
) &&
|
||||
(value.averageGroupSize === null || nonnegative(value.averageGroupSize)) &&
|
||||
(value.soloShare === null || (nonnegative(value.soloShare) && value.soloShare <= 100));
|
||||
const autoscaling = (value: unknown) =>
|
||||
record(value) &&
|
||||
['added', 'removed', 'failed', 'cancelled', 'readinessSamples'].every(
|
||||
(key) => nonnegative(value[key]) && Number.isInteger(value[key])
|
||||
) &&
|
||||
['readinessMeanSeconds', 'readinessMaxSeconds'].every(
|
||||
(key) => value[key] === null || nonnegative(value[key])
|
||||
);
|
||||
const policy = (value: unknown) =>
|
||||
value === null ||
|
||||
(record(value) &&
|
||||
@@ -40,7 +48,8 @@ function validReport(value: unknown): value is MetricsData {
|
||||
!numeric(value.since) ||
|
||||
!totals(value.summary) ||
|
||||
!running(value.running) ||
|
||||
!matches(value.matches)
|
||||
!matches(value.matches) ||
|
||||
!autoscaling(value.autoscaling)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user