Clean up abandoned matches and fix analytics validation
This commit is contained in:
@@ -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}`;
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; });
|
||||
|
||||
Reference in New Issue
Block a user