3.6 KiB
ADR: Session lifecycle — TTL and replay protection
Status
Accepted
Date
2026-04-28
Context
InMemoryGameSessionStore had no TTL and no cleanup mechanism. Every session created stayed in memory until the process restarted. Additionally, evaluateAnswer never removed a question from the answer key after evaluating it, meaning the same question could be submitted multiple times and receive a valid result each time — a potential exploit in multiplayer and a correctness bug in singleplayer.
Decision
Add a ttlMs parameter to GameSessionStore.create() so both the in-memory and future Valkey implementations handle expiry consistently. Delete questions from the answer key after evaluation. Delete the session when the last question is answered.
Options considered
Option A — Delete on last answer only
Simple. Covers replay protection and normal session completion. Abandoned sessions (player starts game, never finishes) still leak memory.
Option B — Delete on last answer + TTL on the interface ✅
Delete on answer covers normal flow. TTL covers abandoned sessions. TTL on the interface means ValKeyGameSessionStore can use Redis-native EXPIRE without any interface changes during migration.
Chosen because it closes the memory leak entirely and makes the Valkey migration a zero-interface-change operation.
Option C — TTL hardcoded inside InMemoryGameSessionStore only
Simpler short-term. But the interface wouldn't carry the TTL parameter, so ValKeyGameSessionStore would need a different mechanism — inconsistency between implementations.
Consequences
- Sessions expire after 30 minutes of inactivity regardless of completion state
- Submitting the same question twice throws
NotFoundErroron the second attempt - Sessions are deleted automatically when the last question is answered
GameSessionStore.create()now requires attlMsargument — any future implementation must honour itValKeyGameSessionStorecan implement TTL via RedisEXPIREwith no interface changesInMemoryGameSessionStorestores{ data, expiresAt }entries instead of rawGameSessionData— expiry is checked lazily onget()
Affected files
apps/api/src/gameSessionStore/GameSessionStore.ts—ttlMsadded tocreateapps/api/src/gameSessionStore/InMemoryGameSessionStore.ts— TTL implementationapps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts— new test fileapps/api/src/services/gameService.ts— passes TTL tostore.create, deletes question after evaluation, deletes session when emptyapps/api/src/services/gameService.test.ts— replay protection and session cleanup tests added
References
Setup guide / implementation notes
-
GameSessionStore.ts— addttlMstocreate:create(sessionId: string, data: GameSessionData, ttlMs: number): Promise<void>; -
InMemoryGameSessionStore.ts— wrap stored data with expiry:type SessionEntry = { data: GameSessionData; expiresAt: number };Check expiry on
get(), delete expired entries lazily. -
gameService.ts— pass TTL when creating session:await store.create(sessionId, { answers: answerKey }, 30 * 60 * 1000);After evaluating an answer:
session.answers.delete(submission.questionId); if (session.answers.size === 0) { await store.delete(submission.sessionId); } -
When implementing
ValKeyGameSessionStore, passttlMsto RedisEXPIRE:await valkey.set(sessionId, serialize(data), "EX", Math.ceil(ttlMs / 1000));