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_idpour 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_idavec un payload différent ; le hash détecte le drift.
Patterns liés
- Inbox (vue architecturale) — la version générique.
- Transactional Outbox — pattern complémentaire côté émetteur.
- Idempotent Receiver EDI — idempotence au niveau métier EDI, complémentaire de l'inbox technique.
- Message Deduplication Ledger — généralisation cross-broker de l'inbox.
- At-Least-Once Delivery — la sémantique broker qui rend l'inbox nécessaire.
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.