Aller au contenu
Lecture : 0 %

Optimistic locking : éviter le Lost Update silencieux

Comment expectedVersion empêche deux retraits concurrents de s'écraser, comment le CommandBus fait un retry, et les règles à tenir en production.

Par Elias Varen3 min de lecture

Deux retraits concurrents arrivent sur le même compte, dont le solde initial est de 1 000 €. Chacun demande 800 €.

Dans un système correct, l'un des deux retraits réussit et l'autre échoue avec « solde insuffisant ». Dans un système sans protection, les deux réussissent et le solde passe à -600 €. Le bug s'est produit en silence, sans exception ni log d'erreur.

On appelle cela le Lost Update, et c'est la première chose qu'un EventStore doit empêcher.

Load, decide, append : la décision qui se périme

Dans un système event-sourcé, toute modification d'un aggregate suit trois étapes :

1. Load     — charger l'état courant depuis le stream
2. Decide   — appliquer la règle métier sur cet état
3. Append   — persister le nouvel événement

Le problème apparaît entre les étapes 2 et 3. Sans protection, la séquence se déroule ainsi :

T=0   Request A  load(account-alice)   → version 5, solde 1000€
T=0   Request B  load(account-alice)   → version 5, solde 1000€
T=1   Request A  decide(withdraw 800)  → solde OK (1000 > 800) ✓
T=1   Request B  decide(withdraw 800)  → solde OK (1000 > 800) ✓
T=2   Request A  append(MoneyWithdrawn 800) → version 6 ✓
T=2   Request B  append(MoneyWithdrawn 800) → version 6 ← CONFLIT !

La requête B a pris sa décision sur la version 5, alors que la requête A a déjà écrit la version 6. Le stream a changé entre le load et l'append, et sans détection, B écrase A.

Déclarer la version attendue au moment de l'append

La parade consiste à déclarer, au moment de l'append, la version qu'on attendait. Si le stream a changé entretemps, l'opération échoue.

TypeScript
// src/core/store/InMemoryEventStore.ts
async append(
  streamName: string,
  envelopes: EventEnvelope[],
  expectedVersion?: number
): Promise<void> {
  const current = this.streams.get(streamName) ?? [];
  const currentVersion = current.length;

  if (expectedVersion !== undefined && expectedVersion !== currentVersion) {
    throw new OptimisticConcurrencyError(
      streamName,
      expectedVersion,
      currentVersion,
    );
  }

  this.streams.set(streamName, [...current, ...envelopes]);
}

expectedVersion a l'air d'un détail d'implémentation. C'est pourtant lui qui garantit que la décision métier s'applique sur l'état qu'elle a lu, et non sur un état intermédiaire créé par une requête concurrente. Un EventStore qui s'en passe corrompt ses données en silence dès que le trafic le permet.

Avec la protection, la même course donne ceci :

T=0   Request A  load(account-alice)   → version 5, solde 1000€
T=0   Request B  load(account-alice)   → version 5, solde 1000€
T=1   Request A  decide(withdraw 800)  → solde OK ✓
T=1   Request B  decide(withdraw 800)  → solde OK ✓
T=2   Request A  append(MoneyWithdrawn 800, expectedVersion=5) → ✓ stream v6
T=2   Request B  append(MoneyWithdrawn 800, expectedVersion=5)
                 → ✗ OptimisticConcurrencyError (expected 5, got 6)

La requête B reçoit une erreur. L'appelant peut alors relancer la commande, recharger le stream, constater que le solde est maintenant de 200 € et prendre la bonne décision, c'est-à-dire refuser le retrait de 800 €.

Un retry dans le CommandBus, et seulement pour ce conflit

L'OptimisticConcurrencyError est la seule erreur pour laquelle un retry automatique a du sens en Event Sourcing. Les autres (violation d'invariant métier, données manquantes) ne se résolvent pas en relançant.

TypeScript
// src/core/command/CommandBus.ts
private async executeWithRetry(command: Command, handler: CommandHandler, policy: RetryPolicy) {
  let attempt = 0;
  while (true) {
    try {
      return await handler.handle(command);
    } catch (err) {
      if (!(err instanceof OptimisticConcurrencyError)) throw err;
      if (attempt >= policy.maxRetries) throw err;

      const delay = policy.baseDelayMs * Math.pow(2, attempt)
                    + Math.random() * policy.jitterMs;
      await sleep(delay);
      attempt++;
    }
  }
}

Le jitter (Math.random() * policy.jitterMs) n'est pas négociable. Sans lui, tous les clients en contention relancent au même instant (le thundering herd) et la contention s'aggrave ; l'aléatoire désynchronise les retries.

Dans PostgreSQL, deux défenses superposées

En production avec PostgreSQL, l'optimistic locking repose sur deux mécanismes complémentaires.

Un SELECT FOR UPDATE avant l'insertion. On verrouille les lignes existantes du stream :

TypeScript
// Concept — pas le code complet
const { max_version } = await client.query(
  'SELECT MAX(event_version) FROM events WHERE stream_name = $1 FOR UPDATE',
  [streamName]
);
if (expectedVersion !== undefined && max_version !== expectedVersion) {
  throw new OptimisticConcurrencyError(streamName, expectedVersion, max_version);
}

Le FOR UPDATE sérialise les accès concurrents sur le même stream, si bien que la requête B attend que A ait commité avant de lire MAX(event_version).

Une contrainte d'unicité.

SQL
UNIQUE (stream_name, event_version)

Quand le FOR UPDATE ne suffit pas (timing extrême, stream encore vide), la contrainte lève 23505, que le code attrape et convertit en OptimisticConcurrencyError. L'application gère les cas courants, la base rattrape les cas limites.

Le handler qui oublie de passer la version

L'interface accepte expectedVersion?: number, optionnel à dessein. Certains cas se passent légitimement de garde de version, comme le premier événement d'un stream ou certains événements d'administration.

L'erreur fréquente consiste à ne pas passer expectedVersion dans les command handlers, par habitude.

TypeScript
// [NON] — pas de protection sur le retrait
await this.repository.save(account); // expectedVersion non passé

// [OUI] — version au moment du chargement
const version = account.version;
account.withdraw(command.amount);
await this.repository.save(account, { expectedVersion: version });

Un handler qui charge et modifie un aggregate doit toujours passer expectedVersion, faute de quoi l'optimistic locking ne protège rien.

Surveiller les conflits et tester la course

Le taux d'OptimisticConcurrencyError se surveille. Un taux bas est normal, puisqu'il s'agit de retries qui réussissent. Un taux qui monte signale un hot aggregate, et la réponse passe le plus souvent par une révision des frontières de l'aggregate plutôt que par une hausse de maxRetries.

La garde de version ne se retire jamais sous charge. La tentation existe quand les conflits montent (« on enlève le guard et on verra »). Elle revient à échanger un problème mesurable contre une corruption silencieuse.

La concurrence se teste avec de vrais tests d'intégration. Deux threads, un compte de 1 000 €, deux retraits de 800 € ; exactement un doit réussir. Sans ce test, l'optimistic locking n'est validé que sur papier.

Le chapitre 4, Les Aggregates en profondeur reprend le sujet plus en détail : snapshots, hot aggregates, bi-temporalité, et stratégies de retry adaptées à différents niveaux de contention.

Le dossier complet : Systèmes event-sourcés

Optimistic locking : éviter le Lost Update silencieux · Deepstack