Aller au contenu

Multi-session pédagogique

PerfShop permet à plusieurs étudiants en parallèle (jusqu'à 500) de jouer un parcours pédagogique sur la même instance, sans interférer entre eux. Chaque étudiant a son propre token UUID, sa propre progression et ses propres tentatives, persistés en base de données.

L'architecture est qualifiée de write-through : la base de données est toujours la source de vérité, et un cache mémoire optionnel peut être activé à chaud par le formateur pour servir un cas d'usage pédagogique précis (démonstration de fuite mémoire visible dans un heap dump).

Source de vérité

Cette page reflète le contrat de l'interface PedagogiqueSessionService.java et son implémentation DefaultPedagogiqueSessionService.java, ainsi que l'entité PedagogiqueSessionEntity.java et la migration V36__add_pedagogique_sessions_table.sql.

Vue d'ensemble

flowchart TB
  subgraph CTRL["Couche contrôleur"]
    CSC["ChaosStudentController<br/>(endpoints /pedagogique/**)"]
    OC["OrderController<br/>(getAgentCodeForToken)"]
  end

  subgraph SVC["Couche service"]
    PSS["PedagogiqueSessionService<br/>(interface)"]
    DPS["DefaultPedagogiqueSessionService"]
    PSS -.implémente.-> DPS
  end

  subgraph DATA["Couche données"]
    direction TB
    CACHE[/"Cache mémoire optionnel<br/>ConcurrentHashMap&lt;token, MutableSession&gt;"/]
    DB[("MySQL<br/>pedagogique_sessions")]
  end

  CTRL --> PSS
  DPS -->|toujours| DB
  DPS -.cache activé.-> CACHE
  CACHE -.fallback.-> DB

Deux modes coexistent dans la même implémentation :

  • Mode database (défaut) : toutes les opérations passent directement par la base. Aucun objet MutableSession ne vit en heap au-delà de la durée d'une requête HTTP. C'est le mode normal, résilient aux redémarrages du backend.
  • Mode database + cache mémoire (opt-in formateur) : chaque session créée est aussi stockée dans une ConcurrentHashMap côté JVM. Les lectures consultent d'abord le cache (rapide), puis la base en fallback. Les sessions vivent en heap, ce qui les rend visibles dans les heap dumps générés par le chaos mémoire — c'est précisément la démonstration pédagogique recherchée.

Pourquoi un cache mémoire optionnel ?

Le cache mémoire n'est pas une optimisation de performance. C'est un support pédagogique : quand le formateur active le chaos mémoire (heap leak) et déclenche un heap dump via /actuator/heapdump, il veut que les étudiants découvrent dans le dump des objets contenant leurs propres tokens, alias et progressions. Sans cache mémoire, la table pedagogique_sessions est la seule structure qui contient ces données — elles restent invisibles dans un heap dump.

flowchart LR
  subgraph A["Mode database<br/>(défaut)"]
    direction TB
    R1["Requête HTTP étudiant"] --> S1["MutableSession<br/>(éphémère, locale<br/>à la requête)"]
    S1 --> DB1[("DB seule")]
    S1 -.GC immédiat.-> X1[/"❌ Invisible<br/>dans heap dump"/]
  end

  subgraph B["Mode database + cache"]
    direction TB
    R2["Requête HTTP étudiant"] --> S2["MutableSession<br/>(persistante en heap)"]
    S2 --> CH2[/"ConcurrentHashMap<br/>en JVM"/]
    S2 --> DB2[("DB toujours mise à jour")]
    CH2 -.heap dump.-> V2[/"✅ Visible :<br/>tokens, alias,<br/>extractionAnswerHash"/]
  end

C'est la valeur pédagogique du chaos mémoire : montrer concrètement qu'une fuite mémoire peut exposer des données utilisateur qui auraient dû rester opaques. Les étudiants peuvent ouvrir le heap dump avec Eclipse MAT ou VisualVM, chercher la classe PedagogiqueSessionService$MutableSession, et lire les tokens des camarades en clair.

Contrat de l'interface PedagogiqueSessionService

L'interface définit le cycle de vie complet d'une session pédagogique. Toutes les méthodes ont une sémantique write-through : elles persistent en base inconditionnellement, et mettent à jour le cache si activé.

Méthodes principales

Méthode Rôle
createSession(alias, level, timerSeconds) Crée une session, calcule agentCode et extractionAnswerHash, persiste en DB, ajoute au cache si activé
getSession(token) Retourne la session par token (cache d'abord si activé, sinon DB), null si inconnu
saveSession(session, level) Persiste les mutations (step, completedAt, attempts) — appelé après chaque mutation
getAgentCodeForToken(token) Récupère le code agent pour OrderController (sans charger toute la session)
clearAll() Vide la table ET le cache
isEmpty() / count() Stats globales
getAllSessions() Vue lecture seule de toutes les sessions actives
setMemoryCacheEnabled(enabled) Bascule le cache à chaud (appelé par ChaosStudentController selon le choix formateur)
isMemoryCacheEnabled() État courant, exposé dans /status pour synchroniser l'UI formateur

Inner class MutableSession

L'objet métier en mémoire (et dans le cache quand actif) :

classDiagram
  class MutableSession {
    +String token "UUID, immuable"
    +String alias "immuable, peut être null"
    +long joinedAt "epoch ms, immuable"
    +AtomicInteger step "incrémentation concurrente"
    +volatile long completedAt "0 si non terminé"
    +ConcurrentHashMap~String,Integer~ attempts "tentatives par étape"
    +volatile String agentCode "ex: A3F7"
    +volatile String extractionAnswerHash "SHA-256"
    +volatile String logiqueQuestionIndices "ex: 15,10,18,19,14"
    +volatile String logiqueExpectedHash "SHA-256"
    +displayAlias() String "alias ou 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 : "conversion bidirectionnelle<br/>(serializeAttempts / deserializeAttempts)"

Thread-safety de MutableSession

Champ Type Pourquoi ?
token, alias, joinedAt final (immuables) Posés au constructeur, jamais modifiés
step AtomicInteger Incrémentations concurrentes potentielles si l'étudiant clique deux fois rapidement
completedAt, agentCode, extractionAnswerHash, logiqueQuestionIndices, logiqueExpectedHash volatile Écriture unique, lecture concurrente — volatile garantit la visibilité
attempts ConcurrentHashMap Mutations concurrentes sur des clés différentes ("bac1-0", "bac1-1"…)

Aucun synchronized n'est utilisé : la composition final + volatile + AtomicInteger + ConcurrentHashMap couvre tous les patterns d'accès observés.

Conversion MutableSessionPedagogiqueSessionEntity

La couche service traduit en permanence entre les deux représentations. Voici la correspondance complète :

MutableSession (Java) PedagogiqueSessionEntity (JPA) Note
token (UUID) token (VARCHAR(36) PK) Identique
alias (String) alias (VARCHAR(100)) null accepté
(porté par le contrôleur) level (VARCHAR(10)) MutableSession ne porte pas le niveau ; il est passé en paramètre à createSession() et à saveSession()
joinedAt (long ms) joined_at (BIGINT) Identique
completedAt (long ms) completed_at (BIGINT, défaut 0) Identique
step.get() (AtomicInteger) current_step (INT) int simple côté DB
agentCode (String) agent_code (VARCHAR(10)) Identique
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) Sérialisé en JSON via Jackson

Pas de DEFAULT sur TEXT

MySQL 8 en mode strict refuse DEFAULT '{}' sur une colonne TEXT. La valeur initiale "{}" pour attempts_json est donc gérée côté Java (serializeAttempts retourne "{}" quand la map est vide). C'est documenté en commentaire dans V36__add_pedagogique_sessions_table.sql.

Diagrammes de séquence

createSession() — création au /join

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

  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() — lecture avec stratégie cache

sequenceDiagram
  autonumber
  participant CTL as Contrôleur
  participant SVC as DefaultPedagogiqueSessionService
  participant CACHE as ConcurrentHashMap
  participant DB as MySQL

  CTL->>SVC: getSession(token)
  alt isMemoryCacheEnabled()
    SVC->>CACHE: get(token)
    alt Hit cache
      CACHE-->>SVC: MutableSession
      SVC-->>CTL: ms
    else Miss cache
      SVC->>DB: SELECT WHERE token = ?
      DB-->>SVC: PedagogiqueSessionEntity ou null
      alt null
        SVC-->>CTL: null
      else trouvé
        SVC->>SVC: ms = entityToMutable(entity)
        SVC->>CACHE: put(token, ms)
        SVC-->>CTL: ms
      end
    end
  else cache désactivé
    SVC->>DB: SELECT WHERE token = ?
    DB-->>SVC: entity ou null
    SVC->>SVC: ms = entityToMutable(entity) si non null
    SVC-->>CTL: ms ou null
  end

saveSession() — write-through

sequenceDiagram
  autonumber
  participant CTL as Contrôleur
  participant SVC as DefaultPedagogiqueSessionService
  participant CACHE as ConcurrentHashMap
  participant DB as MySQL

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

  Note over SVC: Le cache pointe déjà sur le<br/>même objet ms — rien à faire<br/>pour les mutations atomiques<br/>(step, attempts).<br/>On persiste juste en DB.

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

L'optimisation clé : le cache n'est jamais explicitement mis à jour par saveSession(). Il pointe directement sur l'objet MutableSession que le contrôleur vient de muter — donc tout est déjà à jour en mémoire. Seule la base nécessite l'écriture explicite.

Activation/désactivation à chaud

Le formateur bascule le mode via l'interface chaos-admin (chaos pédagogique). Côté backend, l'appel transite par ChaosStudentController qui invoque pedagogiqueSessionService.setMemoryCacheEnabled(enabled). L'état est exposé en lecture dans la réponse de /api/chaos/student/status (champ memoryCacheEnabled) pour que l'UI formateur puisse refléter l'état courant.

stateDiagram-v2
  [*] --> DatabaseOnly : démarrage backend

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

  state WithCache {
    [*] --> Empty
    Empty --> Populated : premier /join
    Populated --> Populated : /join supplémentaires
    Populated --> Populated : getSession (hit ou miss + hydratation)
  }

  state DatabaseOnly {
    [*] --> NoHeap
    NoHeap --> NoHeap : toutes les opérations<br/>passent par la DB
  }

Bascule cache → database

Quand le formateur désactive le cache, la ConcurrentHashMap est vidée. Les sessions existantes ne sont pas perdues (elles restent en base), mais elles disparaissent du heap. Les requêtes suivantes les rechargent depuis la base à la demande. C'est cohérent avec l'invariant write-through : la DB est toujours la source de vérité.

Limites et garde-fous

Limite Valeur Pourquoi ?
Sessions actives simultanées 500 maximum Largement au-dessus du besoin réel (60 étudiants/TP). Au-delà, /join retourne 429 Too Many Requests.
Limite mémoire JVM -Xms256m -Xmx1g (cf. JAVA_OPTS) 500 sessions × ~2 KB ≈ 1 Mo de cache. Largement compatible avec la heap limit.
Purge Pas de purge automatique Les sessions restent en DB après la fin du TP. Le formateur peut appeler clearAll() via l'admin pour repartir d'un état propre.

Pour aller plus loin

  • Authentification — mécanisme X-Student-Token
  • Schéma de données — table pedagogique_sessions (V36)
  • Section Parcours pédagogique (LOT 5) — détail des 5 niveaux et des énigmes
  • Section Chaos engineering (LOT 3) — chaos mémoire et heap dumps