lila/documentation/tickets/t00005.md

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 NotFoundError on the second attempt
  • Sessions are deleted automatically when the last question is answered
  • GameSessionStore.create() now requires a ttlMs argument — any future implementation must honour it
  • ValKeyGameSessionStore can implement TTL via Redis EXPIRE with no interface changes
  • InMemoryGameSessionStore stores { data, expiresAt } entries instead of raw GameSessionData — expiry is checked lazily on get()

Affected files

  • apps/api/src/gameSessionStore/GameSessionStore.tsttlMs added to create
  • apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts — TTL implementation
  • apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts — new test file
  • apps/api/src/services/gameService.ts — passes TTL to store.create, deletes question after evaluation, deletes session when empty
  • apps/api/src/services/gameService.test.ts — replay protection and session cleanup tests added

References


Setup guide / implementation notes

  1. GameSessionStore.ts — add ttlMs to create:

    create(sessionId: string, data: GameSessionData, ttlMs: number): Promise<void>;
    
  2. InMemoryGameSessionStore.ts — wrap stored data with expiry:

    type SessionEntry = { data: GameSessionData; expiresAt: number };
    

    Check expiry on get(), delete expired entries lazily.

  3. 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);
    }
    
  4. When implementing ValKeyGameSessionStore, pass ttlMs to Redis EXPIRE:

    await valkey.set(sessionId, serialize(data), "EX", Math.ceil(ttlMs / 1000));