Fix stale match liveness and stop handling (Refs #136)
CI / checks (push) Successful in 5m15s

This commit is contained in:
2026-10-10 19:30:51 -04:00
parent e3e740cea3
commit 03e2d08a38
10 changed files with 211 additions and 24 deletions
+1
View File
@@ -11,6 +11,7 @@ export interface ServerMatch {
server_deleted_at?: string | null; server_deleted_at?: string | null;
map: string; map: string;
startedAt: string; startedAt: string;
lastReportedAt: string | null;
endedAt: string | null; endedAt: string | null;
status: 'active' | 'completed' | 'interrupted'; status: 'active' | 'completed' | 'interrupted';
startPlayers: number | null; startPlayers: number | null;
+9 -1
View File
@@ -67,7 +67,15 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
'SELECT * FROM leaderboard_matches WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 1' 'SELECT * FROM leaderboard_matches WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 1'
) )
.get(report.server) as Match | undefined; .get(report.server) as Match | undefined;
if (match?.last_fingerprint === signature && match.map_key === identity.key) return; if (match?.last_fingerprint === signature && match.map_key === identity.key) {
// Unchanged gameplay still proves an open match is reporting. Keep the
// duplicate from changing participation, counters, or completed results.
if (match.status === 'active')
handle
.prepare('UPDATE leaderboard_matches SET last_received_at = ? WHERE id = ?')
.run(report.receivedAt, match.id);
if (match.status !== 'interrupted') return;
}
const isStart = report.event === 'game_start'; const isStart = report.event === 'game_start';
const round = isStart ? 0 : report.round; const round = isStart ? 0 : report.round;
+1
View File
@@ -11,6 +11,7 @@ import {
import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js'; import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js';
const matchSummary = `m.id, m.game, m.highest_round AS observedRound, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status, const matchSummary = `m.id, m.game, m.highest_round AS observedRound, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
m.last_received_at AS lastReportedAt,
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt, CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
m.start_players AS startPlayers, m.start_players AS startPlayers,
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers, (SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
+8
View File
@@ -1551,12 +1551,20 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
hold(job, 'Waiting for the agent to verify a stopped container.'); hold(job, 'Waiting for the agent to verify a stopped container.');
return; return;
} }
db().transaction(() => {
db() db()
.prepare( .prepare(
'UPDATE servers SET container_state=?,last_players=NULL,last_roster=NULL WHERE name=?' 'UPDATE servers SET container_state=?,last_players=NULL,last_roster=NULL WHERE name=?'
) )
.run(health.state, job.server); .run(health.state, job.server);
db()
.prepare(
`UPDATE leaderboard_matches SET status='interrupted', ended_at=?
WHERE server=? AND status='active' AND server_deleted_at IS NULL`
)
.run(containerObservedAt, job.server);
complete(job); complete(job);
})();
return; return;
} }
check( check(
+35 -16
View File
@@ -8,6 +8,7 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { timestampISO } from '$lib/time-zone.js'; import { timestampISO } from '$lib/time-zone.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js'; import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import { uiNow } from '$ui/stores/servers.js'; import { uiNow } from '$ui/stores/servers.js';
@@ -331,9 +332,23 @@
> >
<tbody <tbody
>{#each data.matches as match (match.id)} >{#each data.matches as match (match.id)}
{@const reporting =
match.status === 'active' &&
!match.server_deleted_at &&
freshStatusTimestamp(timestampISO(match.lastReportedAt) ?? null, now)}
{@const notReporting = match.status === 'active' && !reporting}
{@const status = notReporting
? 'Not reporting'
: match.status === 'active'
? 'In progress'
: match.status === 'completed'
? 'Completed'
: 'Interrupted'}
{@const duration = {@const duration =
match.status === 'active' match.status === 'active'
? reporting
? elapsedSince(timestampISO(match.startedAt) ?? null, now) ? elapsedSince(timestampISO(match.startedAt) ?? null, now)
: null
: match.durationMs === null : match.durationMs === null
? null ? null
: formatDuration(match.durationMs)} : formatDuration(match.durationMs)}
@@ -346,7 +361,7 @@
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`} aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
>{match.map} · Match #{match.id}</a >{match.map} · Match #{match.id}</a
> >
{:else if match.status === 'active' && !match.server_deleted_at}<a {:else if reporting}<a
class="match-link" class="match-link"
href={contextHref( href={contextHref(
`/servers/${encodeURIComponent(match.server)}`, `/servers/${encodeURIComponent(match.server)}`,
@@ -357,15 +372,17 @@
> >
{:else}<span class="match-name">{match.map}</span>{/if} {:else}<span class="match-name">{match.map}</span>{/if}
<small class="mobile-match-context" <small class="mobile-match-context"
><RelativeTime value={match.startedAt} /> · {match.status === ><RelativeTime value={match.startedAt} /> · {status}{duration !==
'active' null
? 'In progress'
: match.status === 'completed'
? 'Completed'
: 'Interrupted'}{duration !== null
? ` · ${duration}` ? ` · ${duration}`
: ''}</small : ''}</small
> >
{#if notReporting}<small class="mobile-match-context"
>Last report <RelativeTime
value={match.lastReportedAt}
missing="Not recorded"
/></small
>{/if}
{#if server === undefined}<div class="instance"> {#if server === undefined}<div class="instance">
<span title={match.server}>{matchInstanceLabel(match)}</span <span title={match.server}>{matchInstanceLabel(match)}</span
>{#if match.server_deleted_at}<span> >{#if match.server_deleted_at}<span>
@@ -376,14 +393,15 @@
<td <td
><span ><span
class="status" class="status"
class:active={match.status === 'active'} class:active={reporting}
class:interrupted={match.status === 'interrupted'} class:interrupted={match.status === 'interrupted' ||
>{match.status === 'active' notReporting}>{status}</span
? 'In progress' >{#if notReporting}<small
: match.status === 'completed' >Last report <RelativeTime
? 'Completed' value={match.lastReportedAt}
: 'Interrupted'}</span missing="Not recorded"
></td /></small
>{/if}</td
> >
<td class="numeric">{match.uniquePlayers}</td> <td class="numeric">{match.uniquePlayers}</td>
<td class="numeric" <td class="numeric"
@@ -443,7 +461,8 @@
<p> <p>
Players counts unique players across all reports. A dash means the value is unavailable, Players counts unique players across all reports. A dash means the value is unavailable,
not zero. In-progress matches show elapsed time since their recorded start and link to not zero. In-progress matches show elapsed time since their recorded start and link to
the live server. Completed durations require a recorded start and completed ending. the live server while reports are current. Otherwise, they show Not reporting with the
last report time. Completed durations require a recorded start and completed ending.
Round shows the final round for completed matches and the highest observed round for Round shows the final round for completed matches and the highest observed round for
unfinished matches. Open a completed match for exact timestamps and starting and ending unfinished matches. Open a completed match for exact timestamps and starting and ending
player counts. player counts.
+33
View File
@@ -369,6 +369,39 @@ it('refreshes stats during a round without adding rounds or completing the match
expect((await matches()).map((match) => match.status)).toEqual(['completed', 'active']); expect((await matches()).map((match) => match.status)).toEqual(['completed', 'active']);
}); });
it('refreshes the last report for identical heartbeats without changing participation or completed results', async () => {
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
await report('game_start');
await report('heartbeat', 5, [player(undefined, { kills: 100 })]);
const participants = await records();
received += 120;
await report('heartbeat', 5, [player(undefined, { kills: 100 })]);
expect(await matches()).toHaveLength(1);
expect(await records()).toEqual(participants);
expect(serverMatchHistory(server).matches[0]).toMatchObject({
status: 'active',
lastReportedAt: '2026-09-18T20:02:02.000Z'
});
await report('game_end', 5);
const completed = serverMatchHistory(server).matches[0];
await report('game_end', 5);
expect(serverMatchHistory(server).matches[0]).toEqual(completed);
});
it('starts a new match when an identical heartbeat follows an interrupted match', async () => {
await report('heartbeat', 5);
const { db } = await import('../../src/lib/server/db/index.js');
db()
.prepare("UPDATE leaderboard_matches SET status='interrupted', ended_at=last_received_at")
.run();
const interrupted = (await matches())[0];
await report('heartbeat', 5);
expect(await matches()).toEqual([
interrupted,
expect.objectContaining({ status: 'active', highest_round: 5, start_observed: 0 })
]);
});
it('persists independent stat high-water marks without adding cumulative snapshots or retries', async () => { it('persists independent stat high-water marks without adding cumulative snapshots or retries', async () => {
await report('game_start'); await report('game_start');
await report('round_change', 1); await report('round_change', 1);
+1
View File
@@ -91,6 +91,7 @@ it('keeps unknown durations last in either direction and retains historical and
expect(data.matches[0].server_deleted_at).toBeTruthy(); expect(data.matches[0].server_deleted_at).toBeTruthy();
expect(data.matches[1]).toMatchObject({ expect(data.matches[1]).toMatchObject({
status: 'active', status: 'active',
lastReportedAt: '2026-10-04T20:02:00.000Z',
observedRound: 3, observedRound: 3,
finalRound: null, finalRound: null,
durationMs: null durationMs: null
+66
View File
@@ -4000,6 +4000,72 @@ describe('additional concurrency admission', () => {
); );
}); });
describe('match history on server stop', () => {
async function setupStop() {
const job = await queue();
await advance();
await acknowledge(job.deploymentId);
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
const insert = handle.prepare(`INSERT INTO leaderboard_matches
(server, game, map, map_key, map_name, started_at, last_received_at, status,
ended_at, server_deleted_at, highest_round, last_event, last_fingerprint)
VALUES (?, 't5', 'zombie_theater', 't5:zombie_theater', 'Kino Der Toten',
'2026-10-01T00:00:00Z', '2026-10-01T01:00:00Z', ?, ?, ?, 20, 'heartbeat', 'test')`);
insert.run(job.name, 'active', null, null);
insert.run(job.name, 'completed', '2026-10-01T01:00:00Z', null);
insert.run(job.name, 'interrupted', '2026-10-01T01:00:00Z', null);
insert.run(job.name, 'active', null, '2026-10-01T01:00:00Z');
insert.run('another-server', 'active', null, null);
const matches = () => handle.prepare('SELECT * FROM leaderboard_matches ORDER BY id').all();
const before = matches();
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
const stopped = requestLifecycle(job.name, 'stop');
if (!stopped.ok) throw new Error(stopped.error);
await advance();
expect(matches()).toEqual(before);
return { stopped, matches, before };
}
it.each(['stopped', 'exited', 'created', 'dead'])(
'interrupts only the current open match after verifying the container is %s',
async (state) => {
const { stopped, matches, before } = await setupStop();
// A successful command receipt alone does not prove the container stopped.
await acknowledge(stopped.deploymentId);
expect(matches()).toEqual(before);
remote.state = state;
const verifiedAfter = Date.now();
await advance();
expect((await detail(stopped.deploymentId)).state).toBe('done');
const after = matches();
expect(after[0]).toEqual({
...(before[0] as object),
status: 'interrupted',
ended_at: expect.any(String)
});
const endedAt = Date.parse((after[0] as { ended_at: string }).ended_at);
expect(endedAt).toBeGreaterThanOrEqual(verifiedAfter);
expect(endedAt).toBeLessThanOrEqual(Date.now());
expect(after.slice(1)).toEqual(before.slice(1));
await advance();
expect(matches()).toEqual(after);
}
);
it.each(['failed', 'unknown'] as const)(
'keeps the match open when the stop command is %s',
async (state) => {
const { stopped, matches, before } = await setupStop();
const operation = (await operationFor(stopped.deploymentId))!;
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, operation.id, { state, error: 'Stop not confirmed' });
await advance();
expect(matches()).toEqual(before);
}
);
});
describe('game key pool launch lifecycle', () => { describe('game key pool launch lifecycle', () => {
it('reserves across maps atomically, rejects duplicate credentials, and reuses a cancelled launch key', async () => { it('reserves across maps atomically, rejects duplicate credentials, and reuses a cancelled launch key', async () => {
await prepareLibrary(); await prepareLibrary();
+1
View File
@@ -19,6 +19,7 @@ const match: MatchDetail = {
status: 'completed', status: 'completed',
startObserved: true, startObserved: true,
startedAt: '2026-10-07T00:00:00Z', startedAt: '2026-10-07T00:00:00Z',
lastReportedAt: '2026-10-07T00:01:00Z',
endedAt: '2026-10-07T00:01:00Z', endedAt: '2026-10-07T00:01:00Z',
durationMs: 60_000, durationMs: 60_000,
startPlayers: 1, startPlayers: 1,
+49
View File
@@ -17,6 +17,7 @@ const match: ServerMatch = {
finalRound: 20, finalRound: 20,
status: 'completed', status: 'completed',
startedAt: '2026-10-06T17:26:00Z', startedAt: '2026-10-06T17:26:00Z',
lastReportedAt: '2026-10-06T19:59:59Z',
endedAt: '2026-10-06T18:04:00Z', endedAt: '2026-10-06T18:04:00Z',
durationMs: 2280000, durationMs: 2280000,
startPlayers: 2, startPlayers: 2,
@@ -153,6 +154,54 @@ it('labels unfinished rounds and missing durations without inventing final resul
expect(body).toContain('>—</span>'); expect(body).toContain('>—</span>');
expect(body).toContain('>0</td>'); expect(body).toContain('>0</td>');
}); });
it.each([
'2026-10-06T19:58:29Z',
'2026-10-05T18:00:00Z',
'2026-10-06 19:58:29',
'2026-10-06T20:00:02Z',
null,
'invalid'
])('shows a non-live match with its last report when %s is not current', (lastReportedAt) => {
const data = report();
data.matches = [{ ...match, status: 'active', lastReportedAt, durationMs: null }];
const body = render(MatchHistory, { props: { initial: data } }).body.replaceAll(
/<!--.*?-->/g,
''
);
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
expect(rows.match(/Not reporting/g)).toHaveLength(2);
expect(rows.match(/Last report /g)).toHaveLength(2);
expect(rows).not.toContain('In progress');
expect(rows).not.toContain('View live');
expect(rows).not.toContain('<a');
expect(rows).not.toContain('2h 34m');
expect(rows).toContain('>—</span>');
if (lastReportedAt && lastReportedAt !== 'invalid') {
expect(rows).toMatch(/Last report <time [^>]*datetime=/);
} else {
expect(rows).toContain('Last report <span>Not recorded</span>');
}
});
it('expires live links and clocks at the shared freshness boundary and restores them on a new report', () => {
const data = report();
data.matches = [
{ ...match, status: 'active', lastReportedAt: '2026-10-06T19:58:30Z', durationMs: null }
];
const rows = () => {
const body = render(MatchHistory, { props: { initial: data } }).body;
return body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
};
expect(rows()).toContain('View live');
expect(rows()).toContain('2h 34m');
vi.advanceTimersByTime(1000);
expect(rows()).toContain('Not reporting');
expect(rows()).not.toContain('View live');
expect(rows()).not.toContain('2h 34m');
data.matches[0].lastReportedAt = '2026-10-06T20:00:01Z';
expect(rows()).toContain('View live');
expect(rows()).toContain('2h 34m');
expect(rows()).not.toContain('Not reporting');
});
it('offers filter reset for an empty selection and retains the scoped instance view', () => { it('offers filter reset for an empty selection and retains the scoped instance view', () => {
scope('?matchQ=missing'); scope('?matchQ=missing');
const data = report(); const data = report();