L’article précédent sur le Transactional Outbox a posé une garantie claire : tout événement écrit en base finira par être publié. Cette garantie est volontairement at-least-once. Un consommateur peut recevoir le même événement deux fois, trois fois, ou plus si le réseau se comporte mal. Le pattern Outbox ne promet jamais l’unicité.

La conséquence est immédiate : si le consommateur fait deux fois l’effet du message, il facture deux fois, envoie deux emails, débite deux fois le stock. La cohérence garantie côté producteur s’effondre côté lecteur.

Le pattern Inbox répond à ce problème. Il garantit qu’un événement reçu plusieurs fois n’est traité qu’une seule fois, en s’appuyant sur la même primitive que l’Outbox : une transaction SQL locale. C’est le troisième article de la série sur les patterns d’architecture distribuée, après le pattern Saga et le pattern Outbox.

Le problème : at-least-once n’est pas exactly-once

Reprenons l’exemple e-commerce. Un service Facturation consomme un topic Kafka commandes.confirmees. Pour chaque message, il génère une facture PDF et l’envoie au client.

def handle_commande_confirmee(event: dict) -> None:
    facture = Facture.objects.create(commande_id=event["id"])
    pdf = render_pdf(facture)
    email.send(facture.client_email, pdf)

Quatre raisons font qu’un message peut arriver deux fois :

  1. Le relay Outbox côté producteur retry après un crash, et republie un événement déjà publié.
  2. Le worker consommateur traite le message, commit en base, mais crash avant d’acquitter Kafka. Au redémarrage, Kafka le réenvoie.
  3. Un rebalancing de groupe Kafka reattribue une partition à un autre worker qui rejoue le dernier offset.
  4. Un opérateur replay un topic depuis un offset précédent pour debug.

Sans protection, la facture est créée deux fois, le PDF généré deux fois, l’email envoyé deux fois. Le client appelle le support. La promesse d’exactly-once qu’aucun système distribué ne tient vraiment se transforme en at-least-twice visible.

Le principe : tracer ce qui a déjà été traité

Le pattern Inbox repose sur une idée symétrique de l’Outbox : avant de traiter un message, on enregistre son identifiant dans une table inbox. Si l’insert réussit, on traite. S’il échoue parce que l’identifiant existe déjà, on ignore le message. Tout se passe dans une seule transaction SQL atomique, ce qui exclut la fenêtre de course classique entre “vérifier” et “écrire”.

La contrainte essentielle : chaque événement publié par l’Outbox doit porter un identifiant stable et unique. Pas un timestamp, pas une concaténation reconstructible, mais un UUID généré à l’écriture côté producteur et transporté tel quel dans le payload Kafka.

La table inbox

Un schéma minimal côté consommateur :

from django.db import models


class InboxEvent(models.Model):
    event_id = models.UUIDField(primary_key=True)
    consumer = models.CharField(max_length=64)
    processed_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=["consumer", "processed_at"]),
        ]

Deux détails comptent.

L’event_id est la clé primaire. La contrainte d’unicité est la garantie d’idempotence : deux inserts du même event_id lèvent une IntegrityError au niveau base. C’est ce qui rend le pattern infaillible, indépendamment de la concurrence applicative.

Le champ consumer permet à plusieurs services consommateurs de partager la table sans collision. Le service Facturation et le service Analytics peuvent traiter le même événement chacun de leur côté, chacun ayant sa propre ligne d’inbox.

Le traitement atomique

Le handler du message devient :

from django.db import IntegrityError, transaction


def handle_commande_confirmee(event: dict) -> None:
    try:
        with transaction.atomic():
            InboxEvent.objects.create(
                event_id=event["event_id"],
                consumer="facturation",
            )
            facture = Facture.objects.create(commande_id=event["commande_id"])
    except IntegrityError:
        return

    pdf = render_pdf(facture)
    email.send(facture.client_email, pdf)

Trois choix sont importants ici.

L’insert dans InboxEvent et la création de la Facture partagent la même transaction.atomic. Soit les deux sont commités, soit aucun. Impossible d’avoir une trace d’inbox sans facture, ou l’inverse.

L’IntegrityError capture la collision sur la clé primaire. C’est l’équivalent d’un INSERT ... ON CONFLICT DO NOTHING PostgreSQL, mais portable. Pas de SELECT préalable, pas de race condition possible : deux workers qui tentent simultanément n’auront qu’un seul gagnant, déterminé par la base.

L’envoi du PDF et de l’email sort de la transaction. C’est volontaire : on ne veut pas tenir le verrou pendant un appel SMTP de plusieurs secondes. La conséquence : si le worker crash entre la commit et l’email, l’email ne partira jamais. C’est un compromis classique entre at-least-once de la persistence et at-most-once de l’effet de bord externe, à arbitrer selon le métier.

Le piège du side-effect non transactionnel

Le code ci-dessus a une faille assumée : si le worker meurt après la commit mais avant email.send, la facture existe en base, l’inbox aussi, mais aucun email n’est parti. Au prochain rebalancing, Kafka rejouera le message, l’inbox bloquera le traitement, et l’email ne partira jamais.

Deux stratégies existent.

Repousser l’effet de bord dans l’Outbox du consommateur. Au lieu d’envoyer l’email directement, on écrit un événement FactureAGenerer dans une table outbox locale. Un autre worker, plus loin, gère l’envoi avec ses propres garanties at-least-once. Le pattern devient Inbox → traitement → Outbox, et chaque transition est transactionnelle.

Accepter le risque et compenser via un mécanisme externe. Un cron qui détecte les Facture sans email envoyé depuis plus de X minutes et déclenche un renvoi. Plus simple, suffisant dans beaucoup de cas, mais demande une visibilité métier sur “qu’est-ce qu’une facture en attente d’email”.

Le premier est plus rigoureux, le second plus pragmatique. À choisir selon la criticité de l’effet et la tolérance au retard.

La purge de l’inbox

Comme l’Outbox, l’Inbox grossit indéfiniment si on ne la maintient pas. À 10 000 événements par jour, un an de table = 3,5 millions de lignes. La clé primaire reste rapide en lookup, mais la table consomme de l’espace.

Deux approches.

TTL court avec rétention métier. On garde les event_id traités pendant la durée maximale possible de rejeu côté broker (généralement la rétention Kafka, soit 7 ou 14 jours), puis on supprime. Au-delà, un rejeu d’un événement très ancien ne peut plus arriver de toute façon. Une commande supprimée toutes les semaines suffit.

Partitionnement par date. Pour les volumes vraiment élevés, on partitionne InboxEvent par mois via les fonctionnalités natives de PostgreSQL. Le drop d’une partition entière est instantané, là où un DELETE massif peut bloquer.

Outbox + Inbox : la boucle complète

Avec les deux patterns combinés, la trajectoire d’un événement devient :

  1. Le service A écrit l’événement dans son outbox au sein de la transaction métier.
  2. Le relay publie vers Kafka, garantie at-least-once.
  3. Le service B reçoit le message, l’enregistre dans son inbox dans la transaction qui applique l’effet métier.
  4. Si le message arrive une seconde fois, l’IntegrityError sur l’inbox bloque le retraitement.

Le résultat n’est pas exactly-once au sens strict du terme. C’est effectively-once : le système se comporte comme si chaque événement n’avait été traité qu’une fois, alors que techniquement il a pu être reçu plusieurs fois. C’est la garantie qu’on peut vraiment obtenir dans un système distribué.

Quand l’inbox est inutile

Le pattern ajoute une table, un index, une contrainte de schéma sur le payload (l’event_id). Pour un consommateur dont l’opération est naturellement idempotente, c’est de la sur-ingénierie.

Si le handler fait Commande.objects.filter(id=...).update(statut="confirmee"), rejouer dix fois donne le même résultat que rejouer une fois. La déduplication est implicite, l’inbox est inutile.

L’inbox devient pertinent dès que :

  • l’opération a un effet métier non idempotent (créer une ressource, débiter, envoyer, incrémenter un compteur)
  • la perte de l’événement est inacceptable, donc on ne peut pas se contenter d’un “skip si déjà vu” approximatif
  • plusieurs consommateurs traitent la même file et risquent de se voler le travail

À l’inverse, pour un consommateur qui ne fait que mettre à jour un état déterministe à partir du payload, mieux vaut écrire un handler idempotent par construction.

Conclusion

L’Inbox n’est pas un pattern indépendant. Il est la moitié manquante de l’Outbox : sans lui, la garantie at-least-once devient des doublons visibles côté utilisateur. Avec lui, la chaîne complète producteur-broker-consommateur se comporte de façon prévisible, malgré tous les modes de défaillance possibles entre les deux.

Le coût d’implémentation est faible : une table, une contrainte d’unicité, une transaction.atomic. Le bénéfice est de transformer un système où “il faut juste pas qu’un message arrive deux fois” en système où “qu’un message arrive cent fois ne change rien”. C’est exactement le genre de garantie qu’on n’apprécie qu’en production, le jour où Kafka rejoue un offset par accident.