data / sqlalchemy
Transactions et rollback
Explication
Ce que vous allez apprendre
- Comprendre l'atomicité d'une transaction : "tout ou rien" sur plusieurs opérations
- Distinguer précisément ce que fait
commit()de ce que faitrollback() - Éviter le piège d'une session laissée dans un état invalide après une
IntegrityError - Isoler un échec ponctuel sans perdre le reste du travail avec
begin_nested()(savepoints) - Mettre en place un retry automatique face à une erreur de sérialisation
Dans quel contexte ?
Une application bancaire doit transférer 100 euros d'un compte vers un autre : débiter le compte source et créditer le compte destination doivent réussir ENSEMBLE, ou échouer ENSEMBLE. Si le programme plante après le débit mais avant le crédit, de l'argent disparaîtrait purement et simplement sans la garantie transactionnelle que cette leçon met en place.
D'abord, l'idée d'atomicité
Une transaction regroupe plusieurs opérations en un seul bloc "tout ou rien" : soit toutes les modifications sont validées ensemble, soit aucune ne l'est. L'exemple classique est le virement bancaire — débiter un compte et créditer un autre doivent réussir ensemble, sinon de l'argent disparaît ou se duplique si le programme plante entre les deux.
Étape 1 : ce que fait vraiment commit()
La leçon 4 a introduit la session comme "unit of work" qui accumule les changements. Le commit() est le moment précis où cette transaction devient définitive et visible pour les autres connexions à la base.
| Méthode | Effet |
|---|---|
commit() | rend les changements définitifs et visibles pour les autres connexions |
rollback() | annule tout ce qui n'a pas été validé, y compris en mémoire |
begin_nested() | crée un savepoint annulable sans perdre le reste de la transaction |
Étape 2 : ce que fait vraiment rollback()
rollback() annule tout ce qui n'a pas encore été validé — y compris des modifications déjà appliquées en mémoire sur les objets Python. Comprendre cette portée évite un bug subtil : croire qu'une donnée a été annulée alors qu'elle reste modifiée côté objet Python.
Un piège qui surgit juste après une erreur
Après une IntegrityError (par exemple un email en doublon), la session reste dans un état invalide tant qu'un rollback() explicite n'a pas été appelé. Continuer à l'utiliser sans ce rollback provoque des erreurs en cascade, difficiles à comprendre pour qui ne connaît pas cette règle.
Piège fréquent
Après avoir attrapé une IntegrityError, réutiliser immédiatement la session sans appeler session.rollback() provoque une nouvelle erreur ("This Session's transaction has been rolled back due to a previous exception") sur la prochaine opération. Appelez toujours rollback() explicitement dans le except.
Pour aller plus loin : isoler un échec ponctuel
Parfois, on ne veut annuler qu'une seule opération sans perdre tout le travail déjà accumulé autour, par exemple lors d'un import où certaines lignes invalides ne doivent pas bloquer les lignes valides. begin_nested() crée un point de sauvegarde à l'intérieur d'une transaction plus large, permettant d'annuler seulement ce point précis.
Vers la suite
Une fois la logique transactionnelle maîtrisée, la prochaine leçon montre comment aller chercher des informations plus riches qu'un simple filtre : sous-requêtes, CTE et fonctions de fenêtrage.
Commandes & code
Transactions et rollback
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
# La session ouvre une transaction implicite dès la première opération
with SessionLocal() as session:
try:
compte_source = session.get(Compte, 1)
compte_dest = session.get(Compte, 2)
compte_source.solde -= 100
compte_dest.solde += 100
if compte_source.solde < 0:
raise ValueError("Solde insuffisant")
session.commit() # valide les DEUX updates ensemble (atomicité)
except ValueError:
session.rollback() # annule TOUT, y compris le débit déjà appliqué en mémoire
# begin() explicite : contexte transactionnel clair, commit/rollback automatique
with SessionLocal() as session:
with session.begin(): # commit automatique en sortie, rollback si exception
session.add(Utilisateur(email="x@y.com", nom="X"))
# pas besoin d'appeler commit() explicitement ici
# Gérer une violation de contrainte (ex: email UNIQUE déjà pris)
with SessionLocal() as session:
try:
session.add(Utilisateur(email="deja_pris@x.com", nom="Doublon"))
session.commit()
except IntegrityError:
session.rollback() # OBLIGATOIRE avant de réutiliser la session après une erreur
print("Cet email existe déjà")
# Savepoints (nested transactions) avec begin_nested()
with SessionLocal() as session:
session.add(Produit(nom="Stock principal", prix=10))
session.flush()
try:
with session.begin_nested(): # SAVEPOINT
session.add(Produit(nom="Produit invalide", prix=-5)) # viole un CHECK
session.flush() # déclenche l'erreur ici
except IntegrityError:
pass # seul le SAVEPOINT est annulé, "Stock principal" reste en attente
session.commit() # valide "Stock principal", le produit invalide n'a jamais été committé
# Transaction en lecture seule / isolation explicite
with SessionLocal() as session:
session.connection(execution_options={"isolation_level": "SERIALIZABLE"})
resultat = session.scalars(select(Compte).where(Compte.id == 1)).one()
# Retry automatique sur erreur de sérialisation (pattern SERIALIZABLE)
import time
from sqlalchemy.exc import OperationalError
def transferer_avec_retry(session_factory, source_id, dest_id, montant, max_tentatives=3):
for tentative in range(max_tentatives):
try:
with session_factory() as session:
with session.begin():
source = session.get(Compte, source_id)
dest = session.get(Compte, dest_id)
source.solde -= montant
dest.solde += montant
return # succès
except OperationalError:
if tentative == max_tentatives - 1:
raise
time.sleep(0.1 * (tentative + 1)) # backoff simple avant retryRésumé
session.begin()en context manager gère automatiquementcommit()/rollback().- Après une
IntegrityError(ou toute exception SQL), unrollback()explicite est requis avant de réutiliser la session. session.begin_nested()crée unSAVEPOINT: permet d'isoler un échec sans annuler toute la transaction englobante.- Sous isolation
SERIALIZABLE, prévoir une logique de retry applicative en cas d'échec de sérialisation.
Exercices pratiques
Mission : un virement bancaire qui doit réussir ou échouer ensemble
Objectif : Garantir l'atomicité d'un virement entre deux comptes et gérer correctement une erreur métier survenant au milieu de la transaction.
Contexte
Le code de virement fait compte_source.solde -= 100 puis compte_dest.solde += 100, avant de lever une ValueError si le solde source devient négatif, le tout avant tout commit(). Un développeur doute de la portée exacte de session.rollback() dans ce scénario, et une équipe d'import de données veut aussi isoler un échec ponctuel sans perdre le reste du lot déjà traité.