El artículo anterior sobre el Transactional Outbox estableció una garantía clara: todo evento escrito en base acabará siendo publicado. Esa garantía es intencionadamente at-least-once. Un consumidor puede recibir el mismo evento dos veces, tres veces, o más si la red se porta mal. El patrón Outbox nunca promete unicidad.
La consecuencia es inmediata: si el consumidor aplica dos veces el efecto del mensaje, factura dos veces, envía dos correos, descuenta stock dos veces. La coherencia garantizada del lado productor se rompe del lado lector.
El patrón Inbox responde a ese problema. Garantiza que un evento recibido varias veces se procesa una sola vez, apoyándose en la misma primitiva que el Outbox: una transacción SQL local. Es el tercer artículo de la serie sobre patrones de arquitectura distribuida, tras el patrón Saga y el patrón Outbox.
At-least-once no es exactly-once
Volvamos al ejemplo e-commerce. Un servicio Facturación consume un topic Kafka pedidos.confirmados. Para cada mensaje, genera una factura PDF y la envía por correo al cliente.
def handle_pedido_confirmado(event: dict) -> None:
factura = Factura.objects.create(pedido_id=event["id"])
pdf = render_pdf(factura)
email.send(factura.cliente_email, pdf)
Cuatro razones hacen que un mensaje llegue dos veces:
- El relay Outbox del productor reintenta tras un crash y republica un evento ya publicado.
- El worker consumidor procesa el mensaje, hace commit en base, pero crashea antes de acknowledge a Kafka. Al reiniciar, Kafka lo reenvía.
- Un rebalanceo del grupo de consumidores Kafka reasigna una partición a otro worker, que reproduce el último offset.
- Un operador reproduce un topic desde un offset anterior para depurar.
Sin protección, la factura se crea dos veces, el PDF se genera dos veces, el email se envía dos veces. El cliente llama al soporte. La promesa de exactly-once que ningún sistema distribuido cumple realmente se convierte en at-least-twice visible.
El principio: rastrear lo que ya se ha procesado
El patrón Inbox descansa sobre una idea simétrica al Outbox: antes de procesar un mensaje, se registra su identificador en una tabla inbox. Si el insert tiene éxito, se procesa. Si falla porque el identificador ya existe, se ignora el mensaje. Todo ocurre dentro de una única transacción SQL atómica, lo que elimina la condición de carrera clásica entre “comprobar” y “escribir”.
La restricción esencial: cada evento publicado por el Outbox debe llevar un identificador estable y único. No un timestamp, no una concatenación reconstruible, sino un UUID generado en el momento de la escritura del lado productor y transportado tal cual en el payload Kafka.
La tabla inbox
Un esquema mínimo del lado consumidor:
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"]),
]
Importan dos detalles.
event_id es la clave primaria. La restricción de unicidad es la garantía de idempotencia: dos inserts del mismo event_id lanzan un IntegrityError a nivel base. Eso es lo que hace el patrón a prueba de fallos, independientemente de la concurrencia aplicativa.
El campo consumer permite que varios servicios consumidores compartan la tabla sin colisión. El servicio Facturación y el servicio Analítica pueden procesar el mismo evento cada uno por su lado, con su propia fila de inbox.
El procesamiento atómico
El handler del mensaje se convierte en:
from django.db import IntegrityError, transaction
def handle_pedido_confirmado(event: dict) -> None:
try:
with transaction.atomic():
InboxEvent.objects.create(
event_id=event["event_id"],
consumer="facturacion",
)
factura = Factura.objects.create(pedido_id=event["pedido_id"])
except IntegrityError:
return
pdf = render_pdf(factura)
email.send(factura.cliente_email, pdf)
Tres decisiones importan aquí.
El insert en InboxEvent y la creación de la Factura comparten la misma transaction.atomic. O ambos hacen commit, o ninguno. Imposible terminar con una traza de inbox sin factura, o al revés.
El IntegrityError captura la colisión en la clave primaria. Es el equivalente a un INSERT ... ON CONFLICT DO NOTHING de PostgreSQL, pero portable. Sin SELECT previo, sin condición de carrera: dos workers que lo intentan simultáneamente solo tendrán un ganador, decidido por la base.
El envío del PDF y del email queda fuera de la transacción. Es intencional: no se quiere mantener el bloqueo durante una llamada SMTP de varios segundos. La contrapartida: si el worker crashea entre el commit y el email, el email nunca saldrá. Es el compromiso clásico entre at-least-once de la persistencia y at-most-once del efecto colateral externo, a arbitrar según el negocio.
La trampa del efecto colateral no transaccional
El código anterior tiene una grieta asumida: si el worker muere tras el commit pero antes de email.send, la factura existe en base, la inbox también, pero ningún email se ha enviado. En el próximo rebalanceo, Kafka reproducirá el mensaje, la inbox bloqueará el reprocesamiento, y el email nunca saldrá.
Existen dos estrategias.
Empujar el efecto colateral al Outbox del propio consumidor. En lugar de enviar el email directamente, se escribe un evento FacturaPorEnviar en una tabla outbox local. Otro worker, más adelante, gestiona el envío con sus propias garantías at-least-once. El patrón se convierte en Inbox → procesamiento → Outbox, y cada transición es transaccional.
Aceptar el riesgo y compensar vía un mecanismo externo. Un cron que detecta las Factura sin email enviado tras X minutos y lanza un reenvío. Más simple, suficiente en muchos casos, pero exige visibilidad de negocio sobre “qué es una factura esperando email”.
El primero es más riguroso, el segundo más pragmático. Elegir según la criticidad del efecto y la tolerancia al retraso.
La purga de la inbox
Como el Outbox, la Inbox crece indefinidamente si no se mantiene. A 10 000 eventos por día, un año de tabla = 3,5 millones de filas. La clave primaria sigue rápida en lookup, pero la tabla consume espacio.
Dos enfoques.
TTL corto con retención de negocio. Se guardan los event_id procesados durante la duración máxima posible de reproducción del broker (generalmente la retención Kafka, 7 o 14 días), luego se borran. Más allá, una reproducción de un evento muy antiguo no puede ocurrir de todos modos. Un comando de purga semanal basta.
Particionado por fecha. Para volúmenes realmente altos, se particiona InboxEvent por mes con las funcionalidades nativas de PostgreSQL. El drop de una partición entera es instantáneo, mientras que un DELETE masivo puede bloquear.
Outbox + Inbox: cerrar el bucle
Con ambos patrones combinados, la trayectoria de un evento se convierte en:
- El servicio A escribe el evento en su
outboxdentro de la transacción de negocio. - El relay publica a Kafka, garantía at-least-once.
- El servicio B recibe el mensaje, lo registra en su
inboxdentro de la transacción que aplica el efecto de negocio. - Si el mensaje llega una segunda vez, el
IntegrityErroren la inbox bloquea el reprocesamiento.
El resultado no es exactly-once en sentido estricto. Es effectively-once: el sistema se comporta como si cada evento solo hubiera sido procesado una vez, aunque técnicamente haya podido ser recibido varias veces. Esa es la garantía realmente alcanzable en un sistema distribuido.
Cuándo la inbox es innecesaria
El patrón añade una tabla, un índice, una restricción de esquema sobre el payload (el event_id). Para un consumidor cuya operación es naturalmente idempotente, es sobreingeniería.
Si el handler hace Pedido.objects.filter(id=...).update(estado="confirmado"), reproducirlo diez veces da el mismo resultado que ejecutarlo una vez. La deduplicación es implícita, la inbox es inútil.
La Inbox se vuelve pertinente en cuanto:
- la operación tiene un efecto de negocio no idempotente (crear un recurso, cobrar, enviar, incrementar un contador)
- la pérdida del evento es inaceptable, así que no basta con un “skip si ya visto” aproximado
- varios consumidores procesan la misma cola y se arriesgan a robarse trabajo
A la inversa, para un consumidor que solo actualiza un estado determinista a partir del payload, mejor escribir un handler idempotente por construcción.
Conclusión
La Inbox no es un patrón independiente. Es la mitad que falta del Outbox: sin ella, la garantía at-least-once se convierte en duplicados visibles del lado usuario. Con ella, la cadena completa productor-broker-consumidor se comporta de forma previsible, a pesar de todos los modos de fallo posibles en medio.
El coste de implementación es bajo: una tabla, una restricción de unicidad, una transaction.atomic. El beneficio es transformar un sistema donde “solo hace falta que un mensaje no llegue dos veces” en un sistema donde “que un mensaje llegue cien veces no cambia nada”. Exactamente el tipo de garantía que solo se aprecia en producción, el día en que Kafka reproduce un offset por accidente.
