diff --git a/.env.example b/.env.example index e1d089c8..cc0cae80 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,7 @@ LOG_LEVEL=info #Environment NODE_ENV=development +SESSION_IDLE_TIMEOUT_SECONDS=1800 #Cache Warming Service CACHE_WARMING_PORT=3021 diff --git a/src/game-session/controllers/game-session.controller.ts b/src/game-session/controllers/game-session.controller.ts index 83ebf42f..1b04a2c8 100644 --- a/src/game-session/controllers/game-session.controller.ts +++ b/src/game-session/controllers/game-session.controller.ts @@ -50,6 +50,15 @@ export class GameSessionController { }; } + @Post(':id/heartbeat') + async heartbeat(@Param('id') sessionId: string) { + const session = await this.sessionService.heartbeat(sessionId); + return { + message: 'Game session heartbeat recorded', + session, + }; + } + @Get('resume/:userId') async resumeLatestSession(@Param('userId') userId: string) { const session = await this.sessionService.resume(userId); diff --git a/src/game-session/crash-recovery.spec.ts b/src/game-session/crash-recovery.spec.ts index b609a221..3a426ef5 100644 --- a/src/game-session/crash-recovery.spec.ts +++ b/src/game-session/crash-recovery.spec.ts @@ -1,9 +1,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { GameSession } from './entities/game-session.entity'; import { GameSessionService } from './services/game-session.service'; import { CrashRecoveryJob } from './services/crash-recovery.job'; +import { CleanupSessionJob } from './services/cleanup-session.job'; import { PlayerEventsService } from '../player-events/player-events.service'; import { PuzzleVersionService } from '../puzzles/services/puzzle-version.service'; import { CacheService } from '../cache/services/cache.service'; @@ -22,6 +24,8 @@ function makeSession(overrides: Partial = {}): GameSession { status: 'IN_PROGRESS', state: { progressPercent: 40 }, lastActiveAt: new Date(), + lastHeartbeatAt: new Date(), + closeReason: null, suspendedAt: null, totalMoves: 10, hintsUsed: 2, @@ -68,6 +72,7 @@ describe('Crash Recovery — GameSessionService', () => { provide: PuzzleVersionService, useValue: { getCurrentVersionId: jest.fn() }, }, + { provide: EventEmitter2, useValue: { emit: jest.fn() } }, { provide: CacheService, useValue: cache }, ], }).compile(); @@ -99,6 +104,55 @@ describe('Crash Recovery — GameSessionService', () => { }); }); + describe('heartbeat()', () => { + it('refreshes activity and heartbeat for an active session', async () => { + const session = makeSession({ + lastActiveAt: new Date(Date.now() - 60_000), + lastHeartbeatAt: new Date(Date.now() - 60_000), + }); + repo.findOneBy.mockResolvedValueOnce(session); + + const result = await service.heartbeat(SESSION_ID); + + expect(result.lastHeartbeatAt).toBeInstanceOf(Date); + expect(result.lastActiveAt).toEqual(result.lastHeartbeatAt); + expect(repo.save).toHaveBeenCalledWith(session); + }); + + it('rejects heartbeats for sessions that are not active', async () => { + repo.findOneBy.mockResolvedValueOnce( + makeSession({ status: 'COMPLETED' }), + ); + + await expect(service.heartbeat(SESSION_ID)).rejects.toThrow( + ForbiddenException, + ); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when the session does not exist', async () => { + repo.findOneBy.mockResolvedValueOnce(null); + await expect(service.heartbeat(SESSION_ID)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('updateState()', () => { + it('does not refresh the heartbeat for an empty autosave', async () => { + const heartbeatAt = new Date(Date.now() - 60_000); + const session = makeSession({ lastHeartbeatAt: heartbeatAt }); + repo.findOneBy.mockResolvedValueOnce(session); + + const result = await service.updateState(SESSION_ID, {}); + + expect(result.lastHeartbeatAt).toBe(heartbeatAt); + expect(result.lastActiveAt.getTime()).toBeGreaterThan( + heartbeatAt.getTime(), + ); + }); + }); + describe('resumeById()', () => { beforeEach(() => { // Put the session in SUSPENDED state within the grace window @@ -254,7 +308,10 @@ describe('Crash Recovery — CrashRecoveryJob', () => { await job.expireSuspendedSessions(); expect(repo.save).toHaveBeenCalledWith( - expect.objectContaining({ status: 'ABANDONED' }), + expect.objectContaining({ + status: 'ABANDONED', + closeReason: 'DISCONNECT_GRACE_TIMEOUT', + }), ); }); @@ -340,3 +397,31 @@ describe('Crash Recovery — CrashRecoveryJob', () => { expect(repo.save).toHaveBeenCalledTimes(2); }); }); + +describe('CleanupSessionJob', () => { + let job: CleanupSessionJob; + let repo: ReturnType; + + beforeEach(async () => { + repo = makeMockRepo(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CleanupSessionJob, + { provide: getRepositoryToken(GameSession), useValue: repo }, + ], + }).compile(); + job = module.get(CleanupSessionJob); + }); + + it('abandons sessions past the heartbeat timeout with an idle reason', async () => { + await job.handleCron(); + + const [criteria, update] = repo.update.mock.calls[0]; + expect(criteria.status).toBe('IN_PROGRESS'); + expect(criteria.lastHeartbeatAt).toBeDefined(); + expect(update).toEqual({ + status: 'ABANDONED', + closeReason: 'IDLE_TIMEOUT', + }); + }); +}); diff --git a/src/game-session/entities/game-session.entity.ts b/src/game-session/entities/game-session.entity.ts index 5fbc7199..22cc4bdb 100644 --- a/src/game-session/entities/game-session.entity.ts +++ b/src/game-session/entities/game-session.entity.ts @@ -43,6 +43,12 @@ export class GameSession { @Column({ type: 'timestamp', nullable: true }) lastActiveAt: Date; + @Column({ type: 'timestamp', nullable: true }) + lastHeartbeatAt: Date | null; + + @Column({ type: 'varchar', nullable: true }) + closeReason: string | null; + @Column({ type: 'int', default: 0 }) totalMoves: number; diff --git a/src/game-session/services/cleanup-session.job.ts b/src/game-session/services/cleanup-session.job.ts index 918e488c..64a58e88 100644 --- a/src/game-session/services/cleanup-session.job.ts +++ b/src/game-session/services/cleanup-session.job.ts @@ -14,16 +14,21 @@ export class CleanupSessionJob { private readonly sessionRepo: Repository, ) {} - @Cron(CronExpression.EVERY_10_MINUTES) + @Cron(CronExpression.EVERY_MINUTE) async handleCron() { - const threshold = new Date(Date.now() - 1000 * 60 * 30); // 30 mins + const idleTimeoutSeconds = parseInt( + process.env.SESSION_IDLE_TIMEOUT_SECONDS ?? '1800', + 10, + ); + const threshold = new Date(Date.now() - idleTimeoutSeconds * 1000); const result = await this.sessionRepo.update( { - lastActiveAt: LessThan(threshold), + lastHeartbeatAt: LessThan(threshold), status: 'IN_PROGRESS', }, { status: 'ABANDONED', + closeReason: 'IDLE_TIMEOUT', }, ); this.logger.log(`Cleaned up ${result.affected} idle sessions`); diff --git a/src/game-session/services/crash-recovery.job.ts b/src/game-session/services/crash-recovery.job.ts index aee1503b..6f19549c 100644 --- a/src/game-session/services/crash-recovery.job.ts +++ b/src/game-session/services/crash-recovery.job.ts @@ -44,6 +44,7 @@ export class CrashRecoveryJob { session.status = 'ABANDONED'; session.lastActiveAt = new Date(); + session.closeReason = 'DISCONNECT_GRACE_TIMEOUT'; await this.sessionRepo.save(session); await this.playerEventsService.emitPlayerEvent({ diff --git a/src/game-session/services/game-session.service.ts b/src/game-session/services/game-session.service.ts index 4e116f64..678c66bd 100644 --- a/src/game-session/services/game-session.service.ts +++ b/src/game-session/services/game-session.service.ts @@ -6,7 +6,7 @@ import { ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, LessThan } from 'typeorm'; +import { Repository } from 'typeorm'; import { GameSession } from '../entities/game-session.entity'; import { PlayerEventsService } from '../../player-events/player-events.service'; import { PuzzleVersionService } from '../../puzzles/services/puzzle-version.service'; @@ -44,6 +44,7 @@ export class GameSessionService { status: 'IN_PROGRESS', state: {}, lastActiveAt: new Date(), + lastHeartbeatAt: new Date(), }); const savedSession = await this.sessionRepo.save(session); @@ -66,7 +67,23 @@ export class GameSessionService { session.state = { ...session.state, ...partialState }; session.lastActiveAt = new Date(); + if (Object.keys(partialState).length > 0) { + session.lastHeartbeatAt = session.lastActiveAt; + } + + return this.sessionRepo.save(session); + } + + async heartbeat(sessionId: string) { + const session = await this.sessionRepo.findOneBy({ id: sessionId }); + if (!session) throw new NotFoundException('Session not found'); + if (session.status !== 'IN_PROGRESS') { + throw new ForbiddenException('Session is not active'); + } + const now = new Date(); + session.lastActiveAt = now; + session.lastHeartbeatAt = now; return this.sessionRepo.save(session); } @@ -82,6 +99,8 @@ export class GameSessionService { session.status = status; session.lastActiveAt = new Date(); + session.closeReason = + status === 'COMPLETED' ? 'COMPLETED' : 'USER_ABANDONED'; const savedSession = await this.sessionRepo.save(session); @@ -124,6 +143,7 @@ export class GameSessionService { session.status = 'SUSPENDED'; session.suspendedAt = new Date(); session.lastActiveAt = new Date(); + session.lastHeartbeatAt = session.lastActiveAt; const saved = await this.sessionRepo.save(session); // Snapshot full state to Redis so reconnect can restore it exactly @@ -160,6 +180,7 @@ export class GameSessionService { session.status = 'IN_PROGRESS'; session.suspendedAt = null; session.lastActiveAt = new Date(); + session.lastHeartbeatAt = session.lastActiveAt; return this.sessionRepo.save(session); } diff --git a/src/migrations/1792600000000-AddSessionHeartbeatAndCloseReason.ts b/src/migrations/1792600000000-AddSessionHeartbeatAndCloseReason.ts new file mode 100644 index 00000000..cec8da3d --- /dev/null +++ b/src/migrations/1792600000000-AddSessionHeartbeatAndCloseReason.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddSessionHeartbeatAndCloseReason1792600000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('game_sessions'); + if (!table) return; + + if (!table.findColumnByName('lastHeartbeatAt')) { + await queryRunner.addColumn( + 'game_sessions', + new TableColumn({ + name: 'lastHeartbeatAt', + type: 'timestamp', + isNullable: true, + }), + ); + } + await queryRunner.query( + 'UPDATE "game_sessions" SET "lastHeartbeatAt" = "lastActiveAt" WHERE "lastHeartbeatAt" IS NULL', + ); + + if (!table.findColumnByName('closeReason')) { + await queryRunner.addColumn( + 'game_sessions', + new TableColumn({ + name: 'closeReason', + type: 'varchar', + isNullable: true, + }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('game_sessions'); + if (!table) return; + + if (table.findColumnByName('closeReason')) { + await queryRunner.dropColumn('game_sessions', 'closeReason'); + } + if (table.findColumnByName('lastHeartbeatAt')) { + await queryRunner.dropColumn('game_sessions', 'lastHeartbeatAt'); + } + } +}