La transaction, pour de vrai
Il reste un test en échec assumé dans notre suite, depuis le chapitre 29 :
it.fails("Two simultaneous bookings : only one is accepted", async () => { /* ... */ });Et une promesse, depuis le chapitre 6 :
Pour empêcher la modification de l'agrégat, on doit l'isoler au travers d'une transaction.
Trente-trois chapitres plus tard, tenons-la.
Où mettre le BEGIN ?
Première question, et elle décide de tout le reste.
Pas dans la commande. Une commande qui écrit BEGIN connaît SQL, donc connaît la base, donc n'est plus du domaine. Et deux commandes enchaînées dans un même app.run ouvriraient deux transactions distinctes, ce qui ruinerait l'idée.
Pas dans le repository. Une transaction couvre plusieurs repositories : book écrit dans bookings, une autre commande écrira dans bookings et users. Une transaction par repository, ce n'est pas une transaction.
Il reste un seul endroit sensé : le scénario. C'est-à-dire App.run.
Et c'est cohérent avec ce que nous avons construit. app.run([...]) est déjà l'unité de travail : elle a un début, une fin, un fusible qui l'interrompt, et un contexte partagé. Il lui manque juste de dire "tout ou rien".
Un port de plus
App ne doit pas connaître PostgreSQL. Donc la transaction devient une dépendance, comme l'horloge :
export class App {
constructor(dependencies, subscribers = {}) {
this.dependencies = dependencies;
this.subscribers = subscribers;
}
async run(usecases) {
const context = await this.dependencies.unitOfWork.run(async (dependencies) => {
let context = new Context();
for (const usecase of usecases) {
if (context.isOk()) {
context = await usecase(dependencies, context);
}
}
// Une erreur métier annule ce qui a été écrit avant elle.
if (!context.isOk()) {
throw new Rollback(context);
}
return context;
});
// Publication APRÈS la validation, jamais avant.
if (context.isOk()) {
await this.publish(context.events);
}
return context;
}
}Deux points essentiels dans ces quinze lignes.
Le callback reçoit dependencies en paramètre, au lieu d'utiliser this.dependencies. C'est la clé de tout le mécanisme : l'unité de travail passe aux commandes des repositories branchés sur la transaction en cours. Sans cela, chaque requête prendrait une connexion différente du pool, et se retrouverait hors de la transaction — un bug redoutable, parce qu'il ne se voit jamais tant qu'il n'y a qu'un seul utilisateur.
Une erreur métier déclenche un rollback. C'est nouveau, et c'est important. Jusqu'ici, une commande en erreur laissait en place ce que les commandes précédentes avaient écrit. Un scénario à trois commandes dont la troisième échoue laissait le système à moitié modifié.
Le Rollback est une exception de transport, attrapée immédiatement après :
class Rollback extends Error {
constructor(context) { super("rollback"); this.context = context; }
}Nous avons passé le cours à préférer le résultat à l'exception (chapitre 14). Ici l'exception est le seul mécanisme que comprend une transaction : on ne demande pas poliment à PostgreSQL d'annuler, on lui laisse remonter l'erreur. C'est un usage local, technique, invisible depuis le domaine — le contexte ressort par l'autre côté, exactement comme avant.
Deux implémentations
En SQL :
// infra/SQLUnitOfWork.js
export class SQLUnitOfWork {
constructor(pool, buildRepositories) {
this._pool = pool;
this._build = buildRepositories;
}
async run(work) {
const client = await this._pool.connect();
try {
await client.query("begin");
const result = await work(this._build(client)); // repositories liés au client
await client.query("commit");
return result;
} catch (error) {
await client.query("rollback");
if (error instanceof Rollback) return error.context; // erreur métier : normal
throw error; // panne : ça remonte
} finally {
client.release(); // toujours, quoi qu'il arrive
}
}
}Le finally n'est pas décoratif. Une connexion non rendue au pool est perdue pour tout le monde ; dix fois, et l'application se fige en attendant une connexion qui ne reviendra jamais. C'est la panne la plus fréquente des applications Node en production, et elle se produit toujours sur un chemin d'erreur que personne n'a testé.
En mémoire, il n'y a rien à valider ni à annuler. Mais il y a mieux à faire que ne rien faire :
// infra/MemoryUnitOfWork.js
export class MemoryUnitOfWork {
constructor(dependencies) {
this._dependencies = dependencies;
this._queue = Promise.resolve();
}
run(work) {
// Chaque scénario attend la fin du précédent : une file d'attente.
const result = this._queue.then(() => work(this._dependencies));
this._queue = result.catch(() => {}); // un échec ne bloque pas la file
return result.catch((error) => {
if (error instanceof Rollback) return error.context;
throw error;
});
}
}C'est le sémaphore du chapitre 6, troisième option :
En JavaScript, la boucle d'événements est mono-thread. Donc on peut facilement coder une liste globale des hébergements en cours de réservation.
Nous faisons plus simple encore : nous sérialisons tous les scénarios. C'est grossier — deux réservations sur deux logements différents s'attendent inutilement — mais dans un jeu de tests, cela ne coûte rien, et cela nous donne exactement la garantie qu'il faut.
Notre it.fails peut devenir un it :
it("Two simultaneous bookings : only one is accepted", async () => { /* inchangé */ });Il passe. Le second scénario attend le premier, lit un calendrier où la réservation est déjà là, et refuse.
Ce que la transaction ne garantit pas
Attention, parce que c'est ici que beaucoup de développeurs s'arrêtent, avec un faux sentiment de sécurité.
Ouvrez deux transactions PostgreSQL, en niveau d'isolation par défaut (read committed), et faites-leur jouer notre commande :
T1: begin T2: begin
T1: select ... overlapping → 0 T2: select ... overlapping → 0
T1: insert booking
T2: insert booking
T1: commit T2: commitDeux réservations.
read committed garantit que vous ne lisez pas des données non validées. Il ne garantit pas que la réponse à votre select est encore vraie au moment de votre insert. Ce que T1 insère n'existait pas quand T2 a regardé : c'est ce qu'on appelle un fantôme, et aucune relecture ne l'attrape.
Le chapitre 6 l'avait annoncé sans le détailler :
Ce niveau est paramétrable pour chaque transaction. Il doit faire l'objet d'un compromis : plus le niveau d'isolation est élevé, plus le risque d'échec de la commande augmente.
Nous y sommes. Trois remèdes, du moins bon au meilleur.
Le verrou pessimiste. Avant de vérifier, on verrouille la ligne du logement :
select * from accommodations where id = $1 for update;Toute autre transaction voulant le même logement attend. C'est correct, c'est facile à expliquer, et cela sérialise toutes les réservations d'un logement — ce qui est acceptable, puisque c'est rare.
Le niveau serializable. PostgreSQL détecte les entrelacements impossibles et rejette l'une des transactions avec le code 40001. C'est élégant, c'est global, et cela impose de rejouer la transaction rejetée.
La contrainte d'exclusion. Celle du chapitre 36 :
exclude using gist (accommodation_id with =, daterange(starts_on, ends_on, '[)') with &&)
where (status = 'confirmed')Elle ne dépend d'aucun niveau d'isolation, d'aucun ordre d'exécution, d'aucune vigilance du développeur. Deux insert concurrents qui se recouvrent : le second échoue. Toujours.
C'est notre choix, et le raisonnement mérite d'être retenu :
Entre une garantie déclarative tenue par le moteur et une garantie procédurale tenue par votre code, prenez la déclarative. Elle protège aussi contre le code que vous n'avez pas écrit.
Le contrôle dans la commande reste indispensable — c'est lui qui donne un message clair à 99,99 % des utilisateurs. La contrainte attrape le reste, et le chapitre 37 a déjà prévu de traduire son code d'erreur en AccommodationNotAvailable.
Ceinture et bretelles. Sur un invariant qui vaut le chiffre d'affaires de la plateforme, c'est le bon dosage.
Rejouer, donc ne rien faire d'irréversible
Si vous retenez le niveau serializable, ou si vous voulez survivre à un deadlock — qui arrivera — il faut rejouer :
async run(work, attempt = 1) {
try {
return await this.runOnce(work);
} catch (error) {
if (isRetryable(error) && attempt < 3) {
await wait(10 * attempt);
return this.run(work, attempt + 1);
}
throw error;
}
}Et cette possibilité impose une condition à tout ce qu'on écrit dans une commande :
Une commande peut être exécutée deux fois. Elle ne doit donc rien faire d'irréversible.
Rien d'irréversible, c'est-à-dire : pas d'envoi de mail, pas d'appel à une API de paiement, pas d'écriture de fichier, pas de webhook.
Vous reconnaissez le raisonnement du chapitre 34, qui nous avait fait sortir les notifications de la commande. Nous avions invoqué la lisibilité et la fiabilité ; voici la troisième raison, et c'est la plus contraignante : on ne rejoue pas un mail.
C'est aussi pour cela que publish est appelé après unitOfWork.run, en dehors de toute transaction. Une transaction ne contient que des écritures annulables.
Trois règles pour finir
Une transaction est courte. Elle tient un verrou ; tant qu'elle est ouverte, d'autres attendent. Tout ce qui peut être fait avant ou après doit l'être.
Jamais d'appel réseau dans une transaction. Un appel HTTP de trois secondes dans une transaction, c'est un verrou de trois secondes. Multipliez par le trafic et vous obtenez une panne complète, dont la cause est indétectable dans les logs applicatifs.
Une transaction n'est pas un cache. Elle ne rend pas les lectures cohérentes entre deux app.run. Si l'utilisateur voit une page affichée il y a dix minutes, aucune transaction ne l'empêchera de cliquer sur un logement entre-temps réservé. C'est la commande qui refuse — et c'est pour ça qu'elle vérifie.
Notre domaine est complet, testé, transactionnel et persistant. Il tourne toujours dans un navigateur.
Il est temps de le mettre à sa place.
Le code de cette étape est disponible ici (opens in a new tab).