Skip to content

Pedagogical multi-session

PerfShop allows multiple students in parallel (up to 500) to play a pedagogical journey on the same instance, without interfering with each other. Each student has their own UUID token, their own progress and their own attempts, persisted in the database.

The architecture is described as write-through: the database is always the source of truth, and an optional memory cache can be enabled on the fly by the instructor to serve a precise pedagogical use case (demonstrating a memory leak visible in a heap dump).

Source of truth

This page reflects the contract of the PedagogiqueSessionService.java interface and its DefaultPedagogiqueSessionService.java implementation, as well as the PedagogiqueSessionEntity.java entity and the V36__add_pedagogique_sessions_table.sql migration.

Overview

flowchart TB
  subgraph CTRL["Controller layer"]
    CSC["ChaosStudentController<br/>(/pedagogique/** endpoints)"]
    OC["OrderController<br/>(getAgentCodeForToken)"]
  end

  subgraph SVC["Service layer"]
    PSS["PedagogiqueSessionService<br/>(interface)"]
    DPS["DefaultPedagogiqueSessionService"]
    PSS -.implements.-> DPS
  end

  subgraph DATA["Data layer"]
    direction TB
    CACHE[/"Optional memory cache<br/>ConcurrentHashMap&lt;token, MutableSession&gt;"/]
    DB[("MySQL<br/>pedagogique_sessions")]
  end

  CTRL --> PSS
  DPS -->|always| DB
  DPS -.cache enabled.-> CACHE
  CACHE -.fallback.-> DB

Two modes coexist in the same implementation:

  • Database mode (default): all operations go directly through the database. No MutableSession object lives on the heap beyond the duration of an HTTP request. This is the normal mode, resilient to backend restarts.
  • Database + memory cache mode (instructor opt-in): every created session is also stored in a JVM-side ConcurrentHashMap. Reads first check the cache (fast), then fall back to the database. Sessions live on the heap, which makes them visible in heap dumps generated by memory chaos — that's precisely the pedagogical demonstration we want.

Why an optional memory cache?

The memory cache is not a performance optimization. It is a pedagogical aid: when the instructor activates memory chaos (heap leak) and triggers a heap dump via /actuator/heapdump, they want students to discover in the dump objects containing their own tokens, aliases and progress. Without the memory cache, the pedagogique_sessions table is the only structure that contains this data — it stays invisible in a heap dump.

flowchart LR
  subgraph A["Database mode<br/>(default)"]
    direction TB
    R1["Student HTTP request"] --> S1["MutableSession<br/>(ephemeral, local<br/>to the request)"]
    S1 --> DB1[("DB only")]
    S1 -.immediate GC.-> X1[/"❌ Invisible<br/>in heap dump"/]
  end

  subgraph B["Database + cache mode"]
    direction TB
    R2["Student HTTP request"] --> S2["MutableSession<br/>(persistent on heap)"]
    S2 --> CH2[/"ConcurrentHashMap<br/>in JVM"/]
    S2 --> DB2[("DB always updated")]
    CH2 -.heap dump.-> V2[/"✅ Visible:<br/>tokens, aliases,<br/>extractionAnswerHash"/]
  end

This is the pedagogical value of memory chaos: showing concretely that a memory leak can expose user data that should have stayed opaque. Students can open the heap dump with Eclipse MAT or VisualVM, look up the PedagogiqueSessionService$MutableSession class, and read their classmates' tokens in cleartext.

PedagogiqueSessionService interface contract

The interface defines the full lifecycle of a pedagogical session. All methods have write-through semantics: they persist to the database unconditionally, and update the cache if it is enabled.

Main methods

Method Role
createSession(alias, level, timerSeconds) Creates a session, computes agentCode and extractionAnswerHash, persists to DB, adds to cache if enabled
getSession(token) Returns the session by token (cache first if enabled, otherwise DB), null if unknown
saveSession(session, level) Persists mutations (step, completedAt, attempts) — called after every mutation
getAgentCodeForToken(token) Retrieves the agent code for OrderController (without loading the full session)
clearAll() Empties the table AND the cache
isEmpty() / count() Global stats
getAllSessions() Read-only view of all active sessions
setMemoryCacheEnabled(enabled) Toggles the cache on the fly (called by ChaosStudentController based on the instructor's choice)
isMemoryCacheEnabled() Current state, exposed in /status to synchronize the instructor UI

MutableSession inner class

The business object in memory (and in the cache when enabled):

classDiagram
  class MutableSession {
    +String token "UUID, immutable"
    +String alias "immutable, may be null"
    +long joinedAt "epoch ms, immutable"
    +AtomicInteger step "concurrent increment"
    +volatile long completedAt "0 if not completed"
    +ConcurrentHashMap~String,Integer~ attempts "attempts per step"
    +volatile String agentCode "e.g. A3F7"
    +volatile String extractionAnswerHash "SHA-256"
    +volatile String logiqueQuestionIndices "e.g. 15,10,18,19,14"
    +volatile String logiqueExpectedHash "SHA-256"
    +displayAlias() String "alias or Agent-XXXX"
  }

  class PedagogiqueSessionEntity {
    +String token "PK, UUID"
    +String alias
    +String level "bac1..bac5"
    +long joinedAt
    +long completedAt
    +int currentStep
    +String agentCode
    +String extractionAnswerHash
    +String logiqueQuestionIndices
    +String logiqueExpectedHash
    +String attemptsJson "{ \"bac1-0\": 2, ... }"
    +LocalDateTime createdAt
    +displayAlias() String
  }

  MutableSession <..> PedagogiqueSessionEntity : "two-way conversion<br/>(serializeAttempts / deserializeAttempts)"

Thread-safety of MutableSession

Field Type Why?
token, alias, joinedAt final (immutable) Set in the constructor, never modified
step AtomicInteger Potential concurrent increments if the student double-clicks rapidly
completedAt, agentCode, extractionAnswerHash, logiqueQuestionIndices, logiqueExpectedHash volatile Single write, concurrent reads — volatile guarantees visibility
attempts ConcurrentHashMap Concurrent mutations on different keys ("bac1-0", "bac1-1"…)

No synchronized is used: the composition final + volatile + AtomicInteger + ConcurrentHashMap covers all observed access patterns.

MutableSessionPedagogiqueSessionEntity conversion

The service layer constantly translates between the two representations. Here is the full mapping:

MutableSession (Java) PedagogiqueSessionEntity (JPA) Note
token (UUID) token (VARCHAR(36) PK) Identical
alias (String) alias (VARCHAR(100)) null accepted
(carried by the controller) level (VARCHAR(10)) MutableSession does not carry the level; it is passed as a parameter to createSession() and saveSession()
joinedAt (long ms) joined_at (BIGINT) Identical
completedAt (long ms) completed_at (BIGINT, default 0) Identical
step.get() (AtomicInteger) current_step (INT) Plain int on the DB side
agentCode (String) agent_code (VARCHAR(10)) Identical
extractionAnswerHash (String) extraction_answer_hash (VARCHAR(64)) SHA-256 hex
logiqueQuestionIndices (String) logique_question_indices (VARCHAR(20)) Format "15,10,18,19,14"
logiqueExpectedHash (String) logique_expected_hash (VARCHAR(64)) SHA-256 hex
attempts (ConcurrentHashMap) attempts_json (TEXT) Serialized as JSON via Jackson

No DEFAULT on TEXT

MySQL 8 in strict mode refuses DEFAULT '{}' on a TEXT column. The initial value "{}" for attempts_json is therefore handled on the Java side (serializeAttempts returns "{}" when the map is empty). This is documented as a comment in V36__add_pedagogique_sessions_table.sql.

Sequence diagrams

createSession() — creation at /join

sequenceDiagram
  autonumber
  participant CTL as ChaosStudentController
  participant SVC as DefaultPedagogiqueSessionService
  participant DB as MySQL<br/>(pedagogique_sessions)
  participant CACHE as ConcurrentHashMap<br/>(optional)

  CTL->>SVC: createSession(alias, "bac3", 1800)
  SVC->>SVC: token = UUID.randomUUID()
  SVC->>SVC: agentCode = computeAgentCode(level, token)
  SVC->>SVC: extractionAnswerHash = SHA-256(...)
  SVC->>SVC: indices = computeLogiqueIndices(token)
  SVC->>SVC: logiqueExpectedHash = SHA-256(answers)

  SVC->>SVC: ms = new MutableSession(alias)
  SVC->>SVC: ms.agentCode = agentCode (volatile)
  SVC->>SVC: ms.extractionAnswerHash = ... (volatile)
  SVC->>SVC: ms.logiqueQuestionIndices = indices
  SVC->>SVC: ms.logiqueExpectedHash = ...

  SVC->>SVC: entity = new PedagogiqueSessionEntity(token, alias, level, joinedAt)
  SVC->>SVC: copyFields(entity, ms, level)
  SVC->>DB: INSERT pedagogique_sessions

  alt isMemoryCacheEnabled()
    SVC->>CACHE: put(token, ms)
  end

  SVC-->>CTL: ms

getSession() — read with cache strategy

sequenceDiagram
  autonumber
  participant CTL as Controller
  participant SVC as DefaultPedagogiqueSessionService
  participant CACHE as ConcurrentHashMap
  participant DB as MySQL

  CTL->>SVC: getSession(token)
  alt isMemoryCacheEnabled()
    SVC->>CACHE: get(token)
    alt Cache hit
      CACHE-->>SVC: MutableSession
      SVC-->>CTL: ms
    else Cache miss
      SVC->>DB: SELECT WHERE token = ?
      DB-->>SVC: PedagogiqueSessionEntity or null
      alt null
        SVC-->>CTL: null
      else found
        SVC->>SVC: ms = entityToMutable(entity)
        SVC->>CACHE: put(token, ms)
        SVC-->>CTL: ms
      end
    end
  else cache disabled
    SVC->>DB: SELECT WHERE token = ?
    DB-->>SVC: entity or null
    SVC->>SVC: ms = entityToMutable(entity) if not null
    SVC-->>CTL: ms or null
  end

saveSession() — write-through

sequenceDiagram
  autonumber
  participant CTL as Controller
  participant SVC as DefaultPedagogiqueSessionService
  participant CACHE as ConcurrentHashMap
  participant DB as MySQL

  CTL->>SVC: saveSession(ms, "bac3")

  Note over SVC: The cache already points to the<br/>same ms object — nothing to do<br/>for atomic mutations<br/>(step, attempts).<br/>We only persist to DB.

  SVC->>SVC: entity = mutableToEntity(ms, "bac3")
  SVC->>SVC: entity.attemptsJson = ObjectMapper.writeValueAsString(ms.attempts)
  SVC->>DB: UPDATE pedagogique_sessions
  SVC-->>CTL: void

The key optimization: the cache is never explicitly updated by saveSession(). It points directly to the MutableSession object the controller has just mutated — so everything is already up to date in memory. Only the database needs an explicit write.

On-the-fly enable/disable

The instructor toggles the mode via the chaos-admin interface (pedagogical chaos). On the backend side, the call goes through ChaosStudentController which invokes pedagogiqueSessionService.setMemoryCacheEnabled(enabled). The state is exposed for reading in the /api/chaos/student/status response (memoryCacheEnabled field) so the instructor UI can reflect the current state.

stateDiagram-v2
  [*] --> DatabaseOnly : backend startup

  DatabaseOnly --> WithCache : setMemoryCacheEnabled(true)
  WithCache --> DatabaseOnly : setMemoryCacheEnabled(false)

  state WithCache {
    [*] --> Empty
    Empty --> Populated : first /join
    Populated --> Populated : additional /join
    Populated --> Populated : getSession (hit or miss + hydration)
  }

  state DatabaseOnly {
    [*] --> NoHeap
    NoHeap --> NoHeap : all operations<br/>go through the DB
  }

Cache → database switch

When the instructor disables the cache, the ConcurrentHashMap is cleared. Existing sessions are not lost (they remain in the database), but they disappear from the heap. Subsequent requests reload them from the database on demand. This is consistent with the write-through invariant: the DB is always the source of truth.

Limits and safeguards

Limit Value Why?
Concurrent active sessions 500 maximum Far above the actual need (60 students per workshop). Above that, /join returns 429 Too Many Requests.
JVM memory limit -Xms256m -Xmx1g (see JAVA_OPTS) 500 sessions × ~2 KB ≈ 1 MB of cache. Easily compatible with the heap limit.
Purge No automatic purge Sessions remain in the DB after the workshop ends. The instructor can call clearAll() via admin to start from a clean state.

To go further

  • AuthenticationX-Student-Token mechanism
  • Database schemapedagogique_sessions table (V36)
  • Pedagogical journey section — details of the 5 levels and the enigmas
  • Chaos engineering section — memory chaos and heap dumps