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<token, MutableSession>"/]
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
MutableSessionobject 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.
MutableSession ↔ PedagogiqueSessionEntity 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¶
- Authentication —
X-Student-Tokenmechanism - Database schema —
pedagogique_sessionstable (V36) - Pedagogical journey section — details of the 5 levels and the enigmas
- Chaos engineering section — memory chaos and heap dumps