Fix stale match liveness and stop handling (Refs #136)
CI / checks (push) Successful in 5m15s
CI / checks (push) Successful in 5m15s
This commit is contained in:
@@ -11,6 +11,7 @@ export interface ServerMatch {
|
||||
server_deleted_at?: string | null;
|
||||
map: string;
|
||||
startedAt: string;
|
||||
lastReportedAt: string | null;
|
||||
endedAt: string | null;
|
||||
status: 'active' | 'completed' | 'interrupted';
|
||||
startPlayers: number | null;
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
.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 round = isStart ? 0 : report.round;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
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,
|
||||
m.last_received_at AS lastReportedAt,
|
||||
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
|
||||
m.start_players AS startPlayers,
|
||||
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
|
||||
|
||||
@@ -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.');
|
||||
return;
|
||||
}
|
||||
db().transaction(() => {
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE servers SET container_state=?,last_players=NULL,last_roster=NULL WHERE name=?'
|
||||
)
|
||||
.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);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
check(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { timestampISO } from '$lib/time-zone.js';
|
||||
import { freshStatusTimestamp } from '$lib/server-freshness.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
@@ -331,9 +332,23 @@
|
||||
>
|
||||
<tbody
|
||||
>{#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 =
|
||||
match.status === 'active'
|
||||
? reporting
|
||||
? elapsedSince(timestampISO(match.startedAt) ?? null, now)
|
||||
: null
|
||||
: match.durationMs === null
|
||||
? null
|
||||
: formatDuration(match.durationMs)}
|
||||
@@ -346,7 +361,7 @@
|
||||
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
|
||||
>{match.map} · Match #{match.id}</a
|
||||
>
|
||||
{:else if match.status === 'active' && !match.server_deleted_at}<a
|
||||
{:else if reporting}<a
|
||||
class="match-link"
|
||||
href={contextHref(
|
||||
`/servers/${encodeURIComponent(match.server)}`,
|
||||
@@ -357,15 +372,17 @@
|
||||
>
|
||||
{:else}<span class="match-name">{match.map}</span>{/if}
|
||||
<small class="mobile-match-context"
|
||||
><RelativeTime value={match.startedAt} /> · {match.status ===
|
||||
'active'
|
||||
? 'In progress'
|
||||
: match.status === 'completed'
|
||||
? 'Completed'
|
||||
: 'Interrupted'}{duration !== null
|
||||
><RelativeTime value={match.startedAt} /> · {status}{duration !==
|
||||
null
|
||||
? ` · ${duration}`
|
||||
: ''}</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">
|
||||
<span title={match.server}>{matchInstanceLabel(match)}</span
|
||||
>{#if match.server_deleted_at}<span>
|
||||
@@ -376,14 +393,15 @@
|
||||
<td
|
||||
><span
|
||||
class="status"
|
||||
class:active={match.status === 'active'}
|
||||
class:interrupted={match.status === 'interrupted'}
|
||||
>{match.status === 'active'
|
||||
? 'In progress'
|
||||
: match.status === 'completed'
|
||||
? 'Completed'
|
||||
: 'Interrupted'}</span
|
||||
></td
|
||||
class:active={reporting}
|
||||
class:interrupted={match.status === 'interrupted' ||
|
||||
notReporting}>{status}</span
|
||||
>{#if notReporting}<small
|
||||
>Last report <RelativeTime
|
||||
value={match.lastReportedAt}
|
||||
missing="Not recorded"
|
||||
/></small
|
||||
>{/if}</td
|
||||
>
|
||||
<td class="numeric">{match.uniquePlayers}</td>
|
||||
<td class="numeric"
|
||||
@@ -443,7 +461,8 @@
|
||||
<p>
|
||||
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
|
||||
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
|
||||
unfinished matches. Open a completed match for exact timestamps and starting and ending
|
||||
player counts.
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await report('game_start');
|
||||
await report('round_change', 1);
|
||||
|
||||
@@ -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[1]).toMatchObject({
|
||||
status: 'active',
|
||||
lastReportedAt: '2026-10-04T20:02:00.000Z',
|
||||
observedRound: 3,
|
||||
finalRound: null,
|
||||
durationMs: null
|
||||
|
||||
@@ -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', () => {
|
||||
it('reserves across maps atomically, rejects duplicate credentials, and reuses a cancelled launch key', async () => {
|
||||
await prepareLibrary();
|
||||
|
||||
@@ -19,6 +19,7 @@ const match: MatchDetail = {
|
||||
status: 'completed',
|
||||
startObserved: true,
|
||||
startedAt: '2026-10-07T00:00:00Z',
|
||||
lastReportedAt: '2026-10-07T00:01:00Z',
|
||||
endedAt: '2026-10-07T00:01:00Z',
|
||||
durationMs: 60_000,
|
||||
startPlayers: 1,
|
||||
|
||||
@@ -17,6 +17,7 @@ const match: ServerMatch = {
|
||||
finalRound: 20,
|
||||
status: 'completed',
|
||||
startedAt: '2026-10-06T17:26:00Z',
|
||||
lastReportedAt: '2026-10-06T19:59:59Z',
|
||||
endedAt: '2026-10-06T18:04:00Z',
|
||||
durationMs: 2280000,
|
||||
startPlayers: 2,
|
||||
@@ -153,6 +154,54 @@ it('labels unfinished rounds and missing durations without inventing final resul
|
||||
expect(body).toContain('>—</span>');
|
||||
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', () => {
|
||||
scope('?matchQ=missing');
|
||||
const data = report();
|
||||
|
||||
Reference in New Issue
Block a user