Supplement telemetry rosters with fresh RCON pings
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { gameEvent, postTelemetry } from '../helpers/telemetry.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
let at: number;
|
||||
@@ -45,6 +46,19 @@ async function setup(count = 5, hostCount = 1) {
|
||||
}
|
||||
|
||||
describe('host status observations', () => {
|
||||
it('batches occupied telemetry lobbies for pings while skipping empty lobbies', async () => {
|
||||
const api = await setup(2);
|
||||
await postTelemetry(gameEvent('heartbeat', 15, 1, 'server-0-0'));
|
||||
await postTelemetry(gameEvent('heartbeat', 15, 0, 'server-0-1'));
|
||||
const pass = api.fanoutStatus();
|
||||
try {
|
||||
await vi.waitFor(() => expect(api.operations()).toHaveLength(1));
|
||||
expect(api.operations()[0]).toMatchObject({ kind: 'rcon.status-batch',
|
||||
payload: { instances: [{ instanceId: 'instance-0-0', generation: 1 }] } });
|
||||
} finally { api.complete(); await pass; }
|
||||
expect(api.getServer('server-0-1')?.last_seen_at).toBeNull();
|
||||
});
|
||||
|
||||
it('bounds monitoring reads below five per server across thirty instances', async () => {
|
||||
const api = await setup(10, 3);
|
||||
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||
|
||||
@@ -47,6 +47,61 @@ describe('shared server observation', () => {
|
||||
expect(selected.rcon_status).toBe(raw);
|
||||
});
|
||||
|
||||
it('supplements telemetry members with GUID-matched pings without changing scores or names', () => {
|
||||
const telemetry = report();
|
||||
const raw = server({ roster: [
|
||||
{ ...telemetry.players[1], num: 2, score: 10, ping: 0, name: 'Old name' },
|
||||
{ ...telemetry.players[0], num: 3, score: 20, ping: 999 },
|
||||
{ ...telemetry.players[2], num: 4, score: 30, ping: 45, guid: 'different-player' }
|
||||
] });
|
||||
const selected = selectServerObservation(server({ rcon_status: raw }), telemetry, now);
|
||||
expect(selected.roster).toEqual(telemetry.players.map((player, i) => ({
|
||||
name: player.name, guid: player.guid, num: null, score: 5000, ping: [999, 0, null][i]
|
||||
})));
|
||||
expect(selected.last_players).toBe(3);
|
||||
expect(selected.rcon_status).toBe(raw);
|
||||
// A new telemetry report must not renew the RCON sample's age.
|
||||
const expired = selectServerObservation({ ...selected, container_checked_at: at(90_001) }, report(90_001), now + 90_001);
|
||||
expect(expired.health).toBe('online');
|
||||
expect(expired.roster?.map(player => player.ping)).toEqual([null, null, null]);
|
||||
});
|
||||
|
||||
it('prefers telemetry ping, including zero, and fills absent or null readings from RCON', () => {
|
||||
const telemetry = report();
|
||||
telemetry.players[0].ping = 0;
|
||||
telemetry.players[1].ping = null;
|
||||
const raw = server({ roster: telemetry.players.map(player => ({ ...player, num: 0, score: 10, ping: 42 })) });
|
||||
expect(selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.map(player => player.ping)).toEqual([0, 42, 42]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ last_seen_at: at(-90_001) }, { last_seen_at: at(2000) }, { last_seen_at: null },
|
||||
{ last_error: 'timeout', rcon_failures: 2 }, { last_map: 'other-map' }
|
||||
])('does not supplement pings from unusable RCON evidence: %j', changes => {
|
||||
const telemetry = report();
|
||||
const raw = server({ ...changes, roster: [{ ...telemetry.players[0], num: 0, score: 100, ping: 42 }] });
|
||||
expect(selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.[0].ping).toBeNull();
|
||||
});
|
||||
|
||||
it('requires RCON samples after the latest lifecycle and tolerates one transient failure', () => {
|
||||
const telemetry = report();
|
||||
const raw = server({ last_seen_at: at(-90_000), roster: [{ ...telemetry.players[0], num: 0, score: 100, ping: 42 }] });
|
||||
expect(selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.[0].ping).toBe(42);
|
||||
expect(selectServerObservation(server({ rcon_status: raw, telemetry_after: at(-90_000) }), telemetry, now).roster?.[0].ping).toBeNull();
|
||||
expect(selectServerObservation(server({ rcon_status: { ...raw, last_error: 'timeout', rcon_failures: 1 } }), telemetry, now).roster?.[0].ping).toBe(42);
|
||||
});
|
||||
|
||||
it.each(['duplicate', 'missing-guid', 'null', 'negative', 'invalid'])('rejects %s ping matches', mode => {
|
||||
const telemetry = report();
|
||||
const player = { ...telemetry.players[0], num: 0, score: 100, ping: 42 as number | null, guid: telemetry.players[0].guid as string | null };
|
||||
if (mode === 'missing-guid') player.guid = null;
|
||||
if (mode === 'null') player.ping = null;
|
||||
if (mode === 'negative') player.ping = -1;
|
||||
if (mode === 'invalid') player.ping = NaN;
|
||||
const raw = server({ roster: mode === 'duplicate' ? [player, { ...player, ping: 50 }] : [player] });
|
||||
expect(selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.[0].ping).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the latest HTTP or streamed telemetry and falls back without extending freshness', () => {
|
||||
const original = server();
|
||||
const selected = selectServerObservation(server({ rcon_status: original, telemetry: report(0, 2) }), report(-1000), now);
|
||||
|
||||
@@ -372,6 +372,8 @@ describe('database fleet and container readiness', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(startedAt);
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
status: { players: 1, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
expect((await postTelemetry(gameEvent('game_start', 0, 1, 'managed'))).status).toBe(200);
|
||||
expect(getServer('managed')).toMatchObject({ active_since: at(0), inactive_since: null, game_started_at: at(0) });
|
||||
|
||||
@@ -384,7 +386,7 @@ describe('database fleet and container readiness', () => {
|
||||
const events: unknown[] = [];
|
||||
const unsubscribe = subscribe(event => events.push(event));
|
||||
try { await fanoutStatus(); } finally { unsubscribe(); }
|
||||
expect(rcon).not.toHaveBeenCalled();
|
||||
expect(rcon).toHaveBeenCalledOnce();
|
||||
expect((await (await GET()).json()).servers[0]).toMatchObject({
|
||||
active_since: at(0), game_started_at: at(0), last_seen_at: at(19)
|
||||
});
|
||||
@@ -488,7 +490,7 @@ describe('database fleet and container readiness', () => {
|
||||
expect(snapshot.serverNow).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it('uses telemetry consistently in REST, status events, and metrics without polling RCON', async () => {
|
||||
it('supplements telemetry with RCON pings consistently in REST and status events', async () => {
|
||||
const { db, inspect, rcon, fanoutStatus } = await setup();
|
||||
const { GET } = await import('../../src/routes/api/servers/+server.js');
|
||||
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
||||
@@ -496,23 +498,49 @@ describe('database fleet and container readiness', () => {
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
const telemetry = gameEvent('heartbeat', 15, 3, 'managed');
|
||||
telemetry.players[0].ping = 42;
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
status: { players: 4, map: 'zombie_theater', gametype: 'zom', roster: [
|
||||
{ num: 0, guid: telemetry.players[0].guid, name: 'Old name', score: 50, ping: 100 },
|
||||
{ num: 1, guid: telemetry.players[1].guid, name: 'Another old name', score: 100, ping: 30 }
|
||||
] } });
|
||||
await postTelemetry(telemetry);
|
||||
const events: unknown[] = [];
|
||||
const unsubscribe = subscribe(event => events.push(event));
|
||||
try { await fanoutStatus(); } finally { unsubscribe(); }
|
||||
expect(rcon).not.toHaveBeenCalled();
|
||||
expect(rcon).toHaveBeenCalledOnce();
|
||||
const server = (await (await GET()).json()).servers[0];
|
||||
expect(server).toMatchObject({ health: 'online', last_players: 3, status_source: 'telemetry', last_error: null,
|
||||
rcon_status: { last_players: 4, last_error: 'timeout:old process' } });
|
||||
rcon_status: { last_players: 4, last_error: null } });
|
||||
expect(server.roster).toHaveLength(3);
|
||||
expect(server.roster[0]).toMatchObject({ ping: 42, score: 5000 });
|
||||
expect(server.roster[1].ping).toBeNull();
|
||||
expect(server.roster[1]).toMatchObject({ ping: 30, score: 5000, name: 'Player 2' });
|
||||
expect(server.roster[2].ping).toBeNull();
|
||||
expect(events).toContainEqual(expect.objectContaining({ type: 'server-status', entries: [expect.objectContaining({
|
||||
health: 'online', players: 3, roster: server.roster, lastSeenAt: server.last_seen_at
|
||||
})] }));
|
||||
expect(db.prepare("SELECT players FROM metric_samples WHERE server = 'managed'").get()).toEqual({ players: 3 });
|
||||
});
|
||||
|
||||
it('keeps occupied telemetry online when supplementary RCON fails', async () => {
|
||||
const { inspect, rcon, fanoutStatus } = await setup();
|
||||
const { GET } = await import('../../src/routes/api/servers/+server.js');
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
rcon.mockResolvedValue({ ok: false, endpoint: null, reason: 'timeout', detail: 'no reply' });
|
||||
await postTelemetry(gameEvent('heartbeat', 15, 1, 'managed'));
|
||||
await fanoutStatus();
|
||||
expect(rcon).toHaveBeenCalledOnce();
|
||||
expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'online', status_source: 'telemetry',
|
||||
last_players: 1, last_error: null, roster: [{ name: 'Player 1', score: 5000, ping: null }] });
|
||||
});
|
||||
|
||||
it('skips supplementary RCON for empty telemetry rosters', async () => {
|
||||
const { inspect, rcon, fanoutStatus } = await setup();
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
await postTelemetry(gameEvent('heartbeat', 15, 0, 'managed'));
|
||||
await fanoutStatus();
|
||||
expect(rcon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to RCON when telemetry expires and does not renew old reports', async () => {
|
||||
const { db, inspect, rcon, fanoutStatus } = await setup();
|
||||
const { GET } = await import('../../src/routes/api/servers/+server.js');
|
||||
|
||||
Reference in New Issue
Block a user