Clean up abandoned matches and fix analytics validation

This commit is contained in:
2026-10-19 15:23:16 +09:00
parent 65c3941e29
commit 6f44ec0d15
14 changed files with 232 additions and 10 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ export class Analytics {
if (data.eventType === 'client_error' && !CLIENT_ERROR_CODES.includes(data.code as any)) reject('INVALID_ANALYTICS_EVENT', '오류 코드를 확인하세요.');
const gameSummary = data.eventType === 'game_started' || data.eventType === 'game_finished';
if (gameSummary && (!validId(data.matchId) || !['bot', 'friend'].includes(String(data.mode)))) reject('INVALID_ANALYTICS_EVENT', '대전을 확인하세요.');
if (data.eventType === 'game_finished' && (!['win', 'loss', 'draw', 'aborted'].includes(String(data.outcome)) || !['hit', 'timeout', 'double_timeout', 'forfeit', 'connection_failed', 'protocol_error'].includes(String(data.reason)))) reject('INVALID_ANALYTICS_EVENT', '결과를 확인하세요.');
if (data.eventType === 'game_finished' && (!['win', 'loss', 'draw', 'aborted'].includes(String(data.outcome)) || !['hit', 'timeout', 'double_timeout', 'forfeit', 'disconnect', 'double_disconnect', 'connection_failed', 'protocol_error'].includes(String(data.reason)))) reject('INVALID_ANALYTICS_EVENT', '결과를 확인하세요.');
const state = this.activityState(p);
if (data.eventType === 'page_view' && !state.visit?.metadataReady) state.metadata = normalizeMetadata(data.metadata);
const id = gameSummary && p.gameSessionId ? `game:${p.gameSessionId}:${data.matchId}:${data.eventType}` : `${p.playerId}:${data.eventId}`;
+10
View File
@@ -18,6 +18,7 @@ import { registerRankedApi } from './ranked-api.js';
import { createGlobalSettingsStore } from './global-settings.js';
import { gameRoute } from './game-api.js';
import { createHttpOptions, registerHttpOperations, logSafeError } from './http-operations.js';
import { MATCH_CLEANUP_INTERVAL_MS } from '../../constants/presence.js';
export interface ServerOptions { mongoUri: string; databaseName: string; clientDirectory: string; clock?: Clock; adminUser?: string; adminPassword?: string; environment?: 'production' | 'test'; rtc?: RtcServerOptions; trustProxy?: false | string[]; logLevel?: string }
export async function createGameServer(options: ServerOptions): Promise<FastifyInstance> {
const store = await createStatsStore(options);
@@ -33,6 +34,14 @@ export async function createGameServer(options: ServerOptions): Promise<FastifyI
? { revision: current.revision } : { revision: current.revision, settings: current };
});
const gameSessions = await createGameSessionStore(store.db, () => clock.now(), options.environment ?? 'production');
let cleanupClosed = false, cleanupTask: Promise<void> | undefined;
const cleanup = () => {
if (cleanupClosed) return;
cleanupTask = gameSessions.cleanupDisconnectedMatches()
.catch(error => logSafeError(app.log, 'match_cleanup', error))
.finally(() => { if (!cleanupClosed) cleanupTimer = clock.setTimeout(cleanup, MATCH_CLEANUP_INTERVAL_MS); });
};
let cleanupTimer = clock.setTimeout(cleanup, MATCH_CLEANUP_INTERVAL_MS);
registerPresence(io, gameSessions);
const peerRooms = await createRoomStore(store.db, () => clock.now());
const ranked = await createRankedService(store.db, clock, options.environment ?? 'production', () => logSafeError(app.log, 'ranked_background', undefined));
@@ -58,6 +67,7 @@ export async function createGameServer(options: ServerOptions): Promise<FastifyI
}
registerAdmin(app, store, analytics, clock, { user: options.adminUser, password: options.adminPassword }, directory, settings);
app.addHook('preClose', async () => {
cleanupClosed = true; clock.clearTimeout(cleanupTimer); await cleanupTask;
await ranked.close();
await analytics.close();
await new Promise<void>(done => io.close(() => done()));
+46 -2
View File
@@ -8,6 +8,7 @@ import type { FighterAppearance } from '../shared/cosmetics.js';
import type { Pair } from '../shared/types.js';
import { createGlobalSettingsStore } from './global-settings.js';
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
import { MATCH_PRESENCE_GRACE_MS } from '../../constants/presence.js';
export class ApiError extends Error {
constructor(public statusCode: number, public code: string) { super(code); }
@@ -17,14 +18,17 @@ const hash = (value: string) => createHash('sha256').update(value).digest('hex')
interface SessionDocument {
_id: string; tokenHash: string; playerId?: string; environment: string; createdAt: Date; updatedAt: Date; expiresAt: Date;
stats: PlayerStats; lastAppliedSeq: number;
presenceUntil?: Date;
activeMatch?: ActiveMatch & { registerRequestId: string };
lastResult?: { matchId: string; seq: number; fingerprint: string; outcome: string; appliedAt: Date };
lastResult?: { matchId: string; seq: number; fingerprint: string; outcome: string; appliedAt: Date; abandoned?: boolean };
}
export interface RegisterMatch extends Omit<ActiveMatch, 'seq'> { requestId: string; expectedPreviousSeq: number }
export async function createGameSessionStore(db: Db, now: () => number = Date.now, environment = 'production') {
const sessions = db.collection<SessionDocument>('game_sessions');
const recoverUntil = now() + MATCH_PRESENCE_GRACE_MS;
await sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
await sessions.createIndex({ playerId: 1, updatedAt: -1 });
await sessions.createIndex({ presenceUntil: 1 });
const authFilter = (auth: GameSessionAuth) => {
if (!auth || !validId(auth.gameSessionId) || typeof auth.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(auth.token)) throw new ApiError(401, 'INVALID_SESSION');
return { _id: auth.gameSessionId, tokenHash: hash(auth.token), expiresAt: { $gt: new Date(now()) } };
@@ -61,6 +65,45 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no
return publicState(await read({ gameSessionId, token }), { gameSessionId, token });
},
async get(auth: GameSessionAuth) { return publicState(await read(auth), auth); },
async touchPresence(auth: GameSessionAuth) {
const doc = await sessions.findOneAndUpdate(authFilter(auth), {
$max: { presenceUntil: new Date(now() + MATCH_PRESENCE_GRACE_MS) },
}, { returnDocument: 'after' });
if (!doc) throw new ApiError(401, 'INVALID_SESSION');
return publicState(doc, auth);
},
async cleanupDisconnectedMatches() {
// Give existing tabs time to reconnect after a server restart or deployment.
if (now() < recoverUntil) return;
const at = new Date(now());
const expired = { $or: [
{ presenceUntil: { $lte: at } },
{ presenceUntil: { $exists: false }, updatedAt: { $lte: new Date(now() - MATCH_PRESENCE_GRACE_MS) } },
] };
for await (const candidate of sessions.find({ ...expired, activeMatch: { $exists: true } })) {
// Recheck the lease and match in the write: reconnects and result saves may have won the race.
await sessions.updateOne({ ...expired, _id: candidate._id, 'activeMatch.matchId': candidate.activeMatch!.matchId,
'activeMatch.seq': candidate.activeMatch!.seq }, [
{ $set: { lastAppliedSeq: '$activeMatch.seq', updatedAt: at,
lastResult: { matchId: '$activeMatch.matchId', seq: '$activeMatch.seq',
fingerprint: '', outcome: 'aborted', abandoned: true, appliedAt: at } } },
{ $unset: 'activeMatch' },
]);
}
// Rooms may outlive already-saved results. Discover them independently so failures can be retried.
const rooms = db.collection<any>('rooms');
for await (const room of rooms.find({ open: true })) {
const protectedMember = await sessions.findOne({ _id: { $in: room.members }, $or: [
{ activeMatch: { $exists: true } },
{ presenceUntil: { $gt: at } },
{ presenceUntil: { $exists: false }, updatedAt: { $gt: new Date(+at - MATCH_PRESENCE_GRACE_MS) } },
] });
if (protectedMember) continue;
await rooms.updateOne({ _id: room._id, members: room.members, currentMatchId: room.currentMatchId,
round: room.round, status: room.status, open: true },
{ $set: { open: false, status: 'closed' } });
}
},
async register(auth: GameSessionAuth, request: RegisterMatch): Promise<ActiveMatch> {
if (!request || !validId(request.requestId) || !validId(request.matchId) || !['bot', 'friend'].includes(request.mode) || !Number.isSafeInteger(request.expectedPreviousSeq) || request.expectedPreviousSeq < 0) throw new ApiError(400, 'INVALID_REQUEST');
const before = await read(auth);
@@ -86,7 +129,7 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no
const match = { matchId: request.matchId, seq: request.expectedPreviousSeq + 1, mode: request.mode,
...(request.roomId ? { roomId: request.roomId } : {}), appearances, settings, registerRequestId: request.requestId };
const doc = await sessions.findOneAndUpdate({ ...authFilter(auth), lastAppliedSeq: request.expectedPreviousSeq, activeMatch: { $exists: false } }, {
$set: { activeMatch: match, updatedAt: new Date(now()), expiresAt: new Date(now() + GAME_SESSION_TTL_MS) },
$set: { activeMatch: match, presenceUntil: new Date(now() + MATCH_PRESENCE_GRACE_MS), updatedAt: new Date(now()), expiresAt: new Date(now() + GAME_SESSION_TTL_MS) },
}, { returnDocument: 'after' });
if (doc) return active(doc)!;
const current = await read(auth);
@@ -116,6 +159,7 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no
const current = await read(auth);
if (input.seq < current.lastAppliedSeq) return { status: 'stale', stats: current.stats };
if (input.seq === current.lastAppliedSeq && current.lastResult?.matchId === input.matchId) {
if (current.lastResult.abandoned) return { status: 'stale', stats: current.stats };
if (current.lastResult.fingerprint !== fingerprint) throw new ApiError(409, 'RESULT_CONFLICT');
return { status: 'duplicate', stats: current.stats };
}
+1 -1
View File
@@ -36,7 +36,7 @@ export function createHttpOptions(options: { environment?: 'production' | 'test'
};
}
type ErrorOperation = 'http_request' | 'readiness' | 'startup' | 'shutdown' | 'ranked_background';
type ErrorOperation = 'http_request' | 'readiness' | 'startup' | 'shutdown' | 'ranked_background' | 'match_cleanup';
// Deliberately exclude error message, stack, cause, arbitrary name/code and request input.
export function logSafeError(logger: Pick<FastifyBaseLogger, 'error'>, operation: ErrorOperation, error: unknown, request?: FastifyRequest) {
const name = error instanceof Error ? error.name : undefined;
+2 -2
View File
@@ -12,7 +12,7 @@ export function registerPresence(io: Server, sessions: GameSessionStore) {
}
namespace.use((socket, next) => {
const { gameSessionId, token } = socket.handshake.auth;
void sessions.get({ gameSessionId, token }).then(session => {
void sessions.touchPresence({ gameSessionId, token }).then(session => {
socket.data.playerId = session.playerId ?? session.gameSessionId;
next();
}, error => next(new Error(error instanceof ApiError && error.statusCode === 401 ? 'INVALID_SESSION' : 'PRESENCE_UNAVAILABLE')));
@@ -25,7 +25,7 @@ export function registerPresence(io: Server, sessions: GameSessionStore) {
if (checking) return;
checking = true;
const { gameSessionId, token } = socket.handshake.auth;
void sessions.get({ gameSessionId, token }).catch(error => {
void sessions.touchPresence({ gameSessionId, token }).catch(error => {
if (error instanceof ApiError && error.statusCode === 401) socket.disconnect(true);
else socket.conn.close(); // Transport closure permits Socket.IO to reconnect after a transient outage.
}).finally(() => { checking = false; });