BUILDING TO SCALE
FR | EN
Parler de votre projet

Le pattern outbox dans Rails : publier des événements sans perdre de données

L'écart entre l'enregistrement d'une donnée et la publication du message qui l'annonce est l'endroit où les événements disparaissent discrètement. Voici comment le combler.

Publié le 2 février 2025 · 4 min de lecture

Le bug a toujours la même forme : un paiement est capturé, la base de données valide la transaction, puis le processus plante une demi-seconde plus tard, avant que le message « PaymentCaptured » n'atteigne la file. Le client a été débité. Personne en aval ne l'apprendra jamais. Le pattern outbox existe pour rendre cette séquence impossible.

Le problème de la publication après le commit

L'approche intuitive, enregistrer la donnée puis publier un message, laisse un écart inévitable entre les deux étapes. Tout ce qui échoue dans cet intervalle, un crash, une coupure réseau, un déploiement qui tombe au mauvais moment, fait disparaître le message sans bruit alors que la modification en base, elle, est bien appliquée. Publier avant le commit pose le problème inverse : le message part, puis la transaction est annulée, et un consumer a réagi à quelque chose qui ne s'est jamais produit.

TRANSACTION BDD (DONNÉE + OUTBOX) TABLE OUTBOX PROCESSUS RELAIS (POLLING OU CDC) BROKER / CONSUMERS
L'écriture et la publication ne sont jamais réparties sur deux transactions

Écrire l'événement dans la même transaction que la donnée

La solution consiste à ne jamais traiter « enregistrer la ligne » et « annoncer la ligne » comme deux opérations distinctes. À la place, on écrit le message sortant dans une table outbox, dans la même transaction de base de données que le changement métier qu'il décrit. Comme il s'agit de la même transaction, les deux sont validés ensemble ou aucun ne l'est : il ne reste aucun intervalle où un crash puisse se cacher.

ActiveRecord::Base.transaction do
  payment.update!(status: "captured")
  OutboxMessage.create!(
    topic: "payments",
    event_type: "PaymentCaptured",
    payload: { payment_id: payment.id }.to_json
  )
end

Un relais sort les messages, à son propre rythme

Un processus séparé et léger, un poller exécuté toutes les quelques secondes ou un outil de change-data-capture qui lit le write-ahead log de la base, lit les lignes non publiées de l'outbox et les pousse vers le vrai broker, en marquant chacune comme envoyée dès que le broker l'a acquittée. Si ce relais plante en plein milieu d'un lot, il reprend simplement à la dernière ligne non envoyée au redémarrage : rien n'est perdu, et les consumers doivent seulement tolérer un doublon occasionnel.

La seule exigence que cela impose aux consumers

Comme le relais peut, dans de rares cas, livrer deux fois le même message, chaque consumer d'un topic alimenté par une outbox doit être idempotent : traiter deux fois le même événement doit produire le même état final que le traiter une seule fois. C'est un problème modeste et bien connu à résoudre côté consumer, et un bien meilleur compromis que de perdre des événements en silence.