ediverse Explore the platform

Spotlight PEPPOL BIS Billing 3.0 The EU e-invoicing mandate is here — France Sept 2026, Belgium Jan 2026, Germany 2025.

Transactional Inbox (deep dive)

The consumer-side counterpart of Outbox: record the received message key in an inbox table within the same transaction as its effects, ensuring a retry applies the effect at most once.

Problem

All modern brokers (Kafka, RabbitMQ, NATS, SQS) guarantee at-least-once: a message may be delivered multiple times. If the consumer writes to DB on each reception, a broker retry doubles the write (two invoices, two stock movements). How to guarantee side effects of a message are applied at most once?

Forces

  • Brokers rarely offer end-to-end exactly-once; an application-level mechanism is required.
  • An in-memory Set doesn't survive restarts and doesn't work multi-instance.
  • The inbox write + the business write must be atomic for the guarantee.
  • The inbox table grows; a retention (dedup window) is required.
  • The message_id must be unique on the producer side (cf. Idempotency Key).

Solution

Before any side effect, the consumer attempts INSERT INTO inbox (message_id, ...) VALUES (...); if INSERT fails on unique constraint, the message has already been processed — just ACK with no action. Otherwise, apply the business effect in the same transaction as the inbox INSERT. On COMMIT: either both apply and journal, or neither. The broker can retry — we are safe.

Inbox table schema

CREATE TABLE inbox (
  message_id   TEXT PRIMARY KEY,         -- producer-side stable id
  source       TEXT NOT NULL,            -- 'order-service', 'as2-partner-x'
  event_type   TEXT NOT NULL,
  received_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  processed_at TIMESTAMPTZ,
  result       TEXT
);
-- Optional GC partition by month to keep deletes cheap.
CREATE INDEX inbox_received_idx ON inbox (received_at);

Processing flow

Broker delivers message (message_id, payload)
        │
        ▼
BEGIN TX
  INSERT INTO inbox (message_id, ...) ── conflict? ──► ROLLBACK + ACK (no-op)
        │ no conflict
  apply business effect (INSERT order, UPDATE stock, ...)
COMMIT  ── success ──► ACK broker
        │ failure
        ▼
   ROLLBACK + NACK (broker retries)

EDI implementation

An AS2 receiver gets two identical MDNs (the partner resent after a timeout). The consumer of the MdnReceived event computes the key { as2_message_id, mdn_disposition_hash } and attempts inbox INSERT. The second pass hits the unique constraint; we ACK silently. Consequence: a single "delivered" status transition on the order, not two. Same for CONTRL/997 received twice after a partner retry — inbox dedupes.

Anti-patterns

  • Inbox without retention — table bloats, inserts slow down.
  • Inbox not in same transaction as the effect — a crash between the two breaks the guarantee.
  • Too-short dedup window — a message replayed after the window is re-applied.
  • message_id not stable on producer side — a producer retry produces a different id, no dedup.
  • Inbox shared across different services without source prefix — accidental collisions.

Sources