Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ LOG_LEVEL=info

#Environment
NODE_ENV=development
SESSION_IDLE_TIMEOUT_SECONDS=1800

#Cache Warming Service
CACHE_WARMING_PORT=3021
Expand Down
9 changes: 9 additions & 0 deletions src/game-session/controllers/game-session.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
87 changes: 86 additions & 1 deletion src/game-session/crash-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,6 +24,8 @@ function makeSession(overrides: Partial<GameSession> = {}): GameSession {
status: 'IN_PROGRESS',
state: { progressPercent: 40 },
lastActiveAt: new Date(),
lastHeartbeatAt: new Date(),
closeReason: null,
suspendedAt: null,
totalMoves: 10,
hintsUsed: 2,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
}),
);
});

Expand Down Expand Up @@ -340,3 +397,31 @@ describe('Crash Recovery — CrashRecoveryJob', () => {
expect(repo.save).toHaveBeenCalledTimes(2);
});
});

describe('CleanupSessionJob', () => {
let job: CleanupSessionJob;
let repo: ReturnType<typeof makeMockRepo>;

beforeEach(async () => {
repo = makeMockRepo();
const module: TestingModule = await Test.createTestingModule({
providers: [
CleanupSessionJob,
{ provide: getRepositoryToken(GameSession), useValue: repo },
],
}).compile();
job = module.get<CleanupSessionJob>(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',
});
});
});
6 changes: 6 additions & 0 deletions src/game-session/entities/game-session.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
11 changes: 8 additions & 3 deletions src/game-session/services/cleanup-session.job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,21 @@ export class CleanupSessionJob {
private readonly sessionRepo: Repository<GameSession>,
) {}

@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`);
Expand Down
1 change: 1 addition & 0 deletions src/game-session/services/crash-recovery.job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
23 changes: 22 additions & 1 deletion src/game-session/services/game-session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,6 +44,7 @@ export class GameSessionService {
status: 'IN_PROGRESS',
state: {},
lastActiveAt: new Date(),
lastHeartbeatAt: new Date(),
});
const savedSession = await this.sessionRepo.save(session);

Expand All @@ -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);
}

Expand All @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down
47 changes: 47 additions & 0 deletions src/migrations/1792600000000-AddSessionHeartbeatAndCloseReason.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';

export class AddSessionHeartbeatAndCloseReason1792600000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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');
}
}
}