Doublons de réservation : la course entre le SELECT et l'INSERT
L'application de réservation de salles de l'IUT est une API Java 17 / Spring Boot 3 adossée à PostgreSQL 15, utilisée par environ 900 étudiants. Le service tourne sur un seul pod, sans cache. Le endpoint POST /api/reservations commence par if (!repo.existsBySalleAndCreneau(salle, debut, fin)) puis enregistre la réservation. En trois semaines, trois doublons ont été signalés : deux réservations identiques pour la même salle et le même créneau, à quelques millisecondes d'intervalle d'après les horodatages. Les logs Hibernate montrent systématiquement deux SELECT puis deux INSERT.
La suite de tests (JUnit + H2 en mémoire) passe en CI, et l'équipe n'arrive pas à reproduire le bug en local. Le niveau d'isolation est READ COMMITTED (défaut PostgreSQL), et la table reservation n'a qu'une clé primaire technique id, sans aucune contrainte d'unicité métier. Un développeur propose de passer l'isolation en SERIALIZABLE pour « régler le problème » ; un autre suggère d'ajouter un @Transactional sur la méthode, ce qui est déjà le cas.
1. Expliquez la cause racine du doublon, évaluez la proposition SERIALIZABLE, puis proposez un correctif en le comparant au verrou pessimiste et au verrou optimiste.
Read the worked answer
Cause racine : une course entre le SELECT de vérification et l'INSERT. Le code fait un exists puis un save. En READ COMMITTED, chaque transaction ne voit que les lignes déjà validées : les deux requêtes voient « aucun conflit », puis les deux insèrent. C'est un check-then-act classique ; @Transactional n'y change rien, puisque le problème n'est pas l'atomicité de l'écriture mais l'absence de garantie entre la lecture et l'écriture.
SERIALIZABLE : cela empêcherait bien l'anomalie (une transaction avorte avec l'erreur 40001), mais il faut alors implémenter des reprises côté application, le débit baisse, et la règle métier reste implicite : rien dans le schéma n'empêche un import manuel de créer un doublon.
Correctif recommandé : contrainte UNIQUE (salle_id, debut) en base, plus interception de DataIntegrityViolationException pour renvoyer un 409 avec un message lisible. C'est la seule garantie qui tient avec plusieurs instances ou un script d'import. Le verrou pessimiste (SELECT ... FOR UPDATE sur la salle) fonctionne aussi mais sérialise toutes les réservations d'une même salle et allonge les transactions. Le verrou optimiste (@Version) ne protège pas une insertion : il n'y a pas encore de ligne à versionner. On garde le pré-contrôle pour le confort utilisateur, pas pour la sécurité.
2. Pourquoi la CI ne détecte-t-elle pas le problème, et comment écrire un test qui le reproduise de façon fiable ?
Read the worked answer
Pourquoi la CI est aveugle. Deux raisons cumulées : le test tourne sur H2, dont le moteur de verrouillage et les sémantiques de contrainte diffèrent de PostgreSQL ; et il est mono-thread, donc aucun entrelacement concurrent ne peut se produire. Un test vert ne prouve rien sur ce bug : il valide seulement le chemin nominal.
Test de non-régression. Test d'intégration avec Testcontainers sur un PostgreSQL réel, en passant par la couche service ou HTTP pour rester proche du comportement observé. Deux threads synchronisés par une CyclicBarrier placée juste avant l'appel, puis un CountDownLatch pour attendre les deux : sans barrière, on ne teste que le hasard de l'ordonnancement. Assertions : exactement un 201 et un 409, et SELECT count(*) FROM reservation WHERE salle_id = ... AND debut = ... égal à 1. Rejouer le scénario une trentaine de fois dans le même test réduit le risque de faux négatif, le bug étant intermittent.
Complément utile. Un test SQL direct qui insère deux fois la même paire et attend une erreur 23505 valide la contrainte indépendamment du code applicatif. Plus largement, tous les tests touchant aux transactions et aux contraintes devraient tourner sur PostgreSQL en CI plutôt que sur H2.
3. Trois doublons existent déjà en production. Décrivez la migration (nettoyage puis contrainte) sans perte de données ni interruption de service.
Read the worked answer
Étape 1 - Identifier et archiver, pas supprimer. Repérer les groupes avec SELECT salle_id, debut, count(*) FROM reservation GROUP BY 1,2 HAVING count(*) > 1. Choisir la ligne à conserver (la plus ancienne created_at, ou celle confirmée par l'utilisateur), recopier les autres dans une table reservation_doublon avec motif et date de traitement. La suppression définitive se décide avec le service scolarité, pas par le développeur qui code la migration.
Étape 2 - Déployer d'abord une version tolérante. L'application attrape déjà la violation d'unicité et renvoie 409 ; on la met en production avant la contrainte. Dans l'ordre inverse, chaque collision provoquerait une erreur 500 pendant le redéploiement.
Étape 3 - Poser la contrainte sans bloquer les écritures. CREATE UNIQUE INDEX CONCURRENTLY ux_reservation_creneau ON reservation (salle_id, debut); puis ALTER TABLE reservation ADD CONSTRAINT ux_reservation_creneau UNIQUE USING INDEX ux_reservation_creneau;. Cette seconde instruction ne prend qu'un verrou bref, alors qu'un ADD CONSTRAINT direct bloque la table le temps de construire l'index sur 180 000 lignes.
Étape 4 - Documenter le retour arrière dans la migration Flyway : suppression de la contrainte et restauration des lignes archivées. Une migration sans procédure de rollback testée n'est pas terminée.