ediverse Explorer la plateforme

À la une PEPPOL BIS Billing 3.0 L’obligation européenne d’e-invoicing arrive : France sept 2026, Belgique janv 2026, Allemagne 2025.

Transactional Inbox

Le consumer enregistre l'identifiant du message reçu dans une table inbox dans la même transaction que le side effect métier. Si le broker re-livre le message après crash, la requête vu/pas-vu prévient la double exécution.

Problème

Tous les brokers modernes (Kafka, RabbitMQ, SQS, AS4 eDelivery) garantissent au mieux at-least-once : après un crash consumer ou un timeout d'ack, le message est re-livré. Si le consumer traite un événement InvoiceIssued en envoyant un INVOIC à un partenaire et que le process crash après l'envoi mais avant l'ack broker, le retry délivre une seconde INVOIC au partenaire — facture en double. Le pattern Idempotent Receiver au niveau métier (référence contrôle UNB) ne suffit pas : il faut détecter le doublon avant d'exécuter le side effect.

Forces

  • Sémantique broker : at-least-once est universel — exactly-once n'existe pas vraiment, c'est at-least-once + idempotence consumer.
  • Atomicité critique : l'écriture de l'idempotency key et l'exécution du side effect doivent être atomiques sinon le pattern ne tient pas.
  • Espace mémoire : la table inbox grossit linéairement, doit être purgée (typiquement après 30 jours, par event partition).
  • Cardinalité : une inbox par bounded context, indexée par message_id pour lookup O(log n).
  • Performance : une lecture supplémentaire par message — coût acceptable si la table est correctement indexée et purgée.

Solution

Le consumer démarre une transaction DB. Première étape : tenter d'insérer l'id du message dans la table inbox (avec contrainte unique). Si l'insert réussit, c'est un message neuf — exécuter le side effect dans la même transaction, puis commit. Si l'insert échoue (violation de contrainte unique), c'est un doublon — skip le side effect, commit l'ack broker. Le coup de génie : side effect ET marque « déjà traité » sont atomiques par la même transaction DB locale, sans XA.

Structure

Consumer pseudo-code:

while (msg = broker.poll()) {
  BEGIN TX;
  TRY {
    INSERT INTO inbox (message_id, source, received_at)
    VALUES (msg.id, msg.source, now());
    // Succès → message neuf

    // Side effect métier dans la même TX
    INSERT INTO partner_invoices (...) VALUES (...);
    INSERT INTO outbox (...) VALUES (...);

    COMMIT;
    broker.ack(msg);
  } CATCH UNIQUE_VIOLATION {
    // Doublon → skip silencieusement
    ROLLBACK;  // pas de side effect
    broker.ack(msg);  // mais ack quand même
    metrics.duplicates.inc();
  }
}

Implémentation EDI

Pour un hub EDI consumer d'événements Kafka, la table inbox doit identifier de façon unique chaque message livré. Sur Kafka, la clé naturelle est (topic, partition, offset) :

-- Table inbox PostgreSQL
CREATE TABLE inbox (
  message_id     VARCHAR(128) PRIMARY KEY,
  source_topic   VARCHAR(80) NOT NULL,
  source_offset  BIGINT NOT NULL,
  consumer_group VARCHAR(80) NOT NULL,
  payload_hash   VARCHAR(64),         -- SHA-256 du payload pour detect drift
  received_at    TIMESTAMPTZ DEFAULT now(),
  processed_at   TIMESTAMPTZ,
  partition_key  VARCHAR(80)
);
CREATE INDEX inbox_received_at_idx ON inbox (received_at);

-- Purge automatique après 30 jours
DELETE FROM inbox WHERE received_at < now() - INTERVAL '30 days';

-- Consumer transactionnel Node.js (pseudo)
async function processMessage(msg) {
  const tx = await db.beginTransaction();
  try {
    await tx.query(
      'INSERT INTO inbox (message_id, source_topic, source_offset, consumer_group)
       VALUES ($1, $2, $3, $4)',
      [msg.id, msg.topic, msg.offset, 'edi-invoicer']
    );
    // Si on arrive ici, message neuf

    await tx.query('INSERT INTO partner_invoices ...');
    await tx.query('INSERT INTO outbox ...');

    await tx.commit();
    await kafkaConsumer.commitOffsets([msg]);
  } catch (err) {
    await tx.rollback();
    if (err.code === '23505') {  // unique_violation PostgreSQL
      // Doublon connu → ack pour ne pas le revoir
      await kafkaConsumer.commitOffsets([msg]);
      metrics.duplicatesSkipped.inc();
    } else {
      throw err;  // Erreur autre → DLQ
    }
  }
}

Pour les flux EDI ingérés depuis AS4, la MessageId AS4 (UUID dans le header WS-Addressing) est la clé naturelle. Pour EDIFACT, c'est la 0020 Interchange Control Reference du UNB combinée à (0004 Sender Identification, 0010 Recipient Identification). L'inbox doit conserver l'historique au moins aussi longtemps que la fenêtre maximale de retry du partenaire (typiquement 7-30 jours).

Anti-patterns

  • Vérifier l'inbox après le side effect — perd l'atomicité, doublon possible si crash entre side effect et insert inbox.
  • Inbox dans Redis (mémoire volatile) — un crash Redis perd l'idempotence, doublons possibles.
  • Pas de purge — la table grossit linéairement, performance dégrade après quelques mois.
  • Confondre inbox et journal d'audit — l'inbox est tactique (anti-doublon), pas légal (cf Event Sourcing).
  • Ne pas hasher le payload — un attaquant pourrait re-soumettre le même message_id avec un payload différent ; le hash détecte le drift.

Patterns liés

Sources

  • Vasters C. — Idempotent Receiver Pattern, Microsoft Azure Architecture. learn.microsoft.com
  • Helland P. — Idempotence Is Not a Medical Condition, ACM Queue 2012. La référence philosophique du pattern. queue.acm.org
  • Kleppmann M. — Designing Data-Intensive Applications, O'Reilly 2017, §11.5 (« Faults and Idempotence »).
  • Confluent — Exactly-Once Semantics Are Possible: Here's How Kafka Does It, 2017. Détaille pourquoi exactly-once chez Kafka est at-least-once + idempotence consumer.
  • RFC 7240 — HTTP Idempotency Key Header Field (Snell, 2014). Norme HTTP transposable aux brokers messages.