← Volver a los relatos
· 13 min de lectura

Sincronizar inventario y órdenes entre plataformas sin perder eventos

Por qué el cron de cada 15 minutos te miente, y cómo un diseño event-driven con outbox, orden e idempotencia mantiene el stock consistente entre sistemas.

arquitecturaevent-drivenintegraciones

El ticket decía “vendimos algo que no teníamos”. Otra vez. La tienda mostraba tres unidades disponibles, el sistema de gestión decía cero, y un cliente acababa de pagar por una de las que no existían. El equipo de operaciones ya tenía un ritual para esto: exportar las dos hojas de cálculo un lunes por la mañana, cruzarlas a mano y corregir las diferencias antes de que alguien más comprara aire.

El stock vivía en un sistema, las órdenes en otro, y entre ambos había un cron que cada quince minutos copiaba el estado completo de un lado al otro. Funcionaba el 99% del tiempo. El problema es que un eCommerce con tráfico real no se rompe en el 99%: se rompe en el 1% que cae justo en el pico de ventas, y ese 1% es exactamente donde el cron falla.

Esta es la arquitectura con la que dejé de cruzar hojas a mano: dejar de sincronizar estados y empezar a sincronizar hechos.

El cron que te miente

El patrón del cron periódico es tentador porque es simple: cada quince minutos, leo todo el stock de origen y lo escribo en destino. Si los dos lados coinciden, no pasa nada. Si no, gana el último que escribió.

Y ahí está la trampa, en tres formas:

  • La foto nace vieja. Entre que el cron lee el origen y termina de escribir el destino pasaron segundos o minutos en los que hubo ventas. Esas ventas no estaban en la foto, así que el destino queda con un número que ya no es cierto.
  • El último que escribe pisa al anterior. Si una venta y una reposición ocurren en la misma ventana, el orden en que el cron los aplica decide el resultado. Aplica la reposición después de leer pero antes de la venta, y acabas de regalar stock.
  • Un deploy a mitad de camino deja una ejecución a medias. El cron no es transaccional con el resto del mundo. Si el proceso muere a la mitad, no hay registro de qué alcanzó a copiar y qué no. La siguiente ejecución parte de cero y confía en que todo esté bien.

Ninguno de estos se arregla corriendo el cron más seguido. Correrlo cada minuto en vez de cada quince solo reduce la ventana del error, no lo elimina, y multiplica la carga sobre dos sistemas que ya están ocupados vendiendo.

Deja de sincronizar estados; sincroniza hechos

El cambio de fondo es dejar de preguntar “¿cuánto stock hay ahora?” y empezar a registrar “¿qué pasó?”. Una venta no es un número nuevo de stock: es el hecho se vendió una unidad del SKU X por la orden Y, ocurrido en un momento preciso. Una reposición es otro hecho. El stock actual es simplemente el resultado de aplicar todos los hechos en orden.

Un hecho tiene tres propiedades que un snapshot no tiene: es inmutable (pasó, no se reescribe), está ordenado (sé cuál vino antes), y es idempotente de aplicar si lo diseñas bien (aplicarlo dos veces da el mismo resultado que una). Esas tres propiedades son justo las que le faltaban al cron.

Sistemaorigen Tablaoutbox Relay Bus deeventos Consumidoridempotente
Cada cambio nace como un hecho en la tabla outbox, dentro de la misma transacción que lo provocó. El relay lo publica al bus en orden, y el consumidor lo aplica una sola vez en el destino.

A partir de aquí, los tres problemas del cron se vuelven tres decisiones de diseño concretas.

Agujero 1: el evento que se pierde entre tu base de datos y el bus

El primer instinto al pasar a eventos es: cuando proceso una venta, escribo en mi base de datos y después publico el evento en el bus. Dos operaciones, dos sistemas. Y ahí vive el bug más sutil de todos, el dual-write: si la base de datos confirma pero el publish al bus falla (timeout, deploy, el bus caído un segundo), la venta existe pero el evento no. Nadie afuera se entera. El stock queda mal y no hay ni rastro de por qué.

No puedes hacer las dos cosas atómicas si son dos sistemas distintos. Pero sí puedes escribir el cambio y su evento en la misma transacción de tu base de datos, en una tabla outbox:

-- El cambio de negocio y su evento se escriben en la MISMA transacción.
-- Si la transacción confirma, ambos existen. Si no, ninguno. Nunca uno sin el otro.
BEGIN;

UPDATE inventory
   SET qty = qty - 1
 WHERE sku = 'ACME-001' AND qty >= 1;

INSERT INTO outbox (id, aggregate, aggregate_id, seq, type, payload, created_at)
VALUES (
  gen_random_uuid(),
  'inventory', 'ACME-001',
  nextval('inventory_seq'),
  'stock.decremented',
  '{"sku":"ACME-001","delta":-1,"reason":"order:9087"}',
  now()
);

COMMIT;

Un proceso aparte, el relay, lee la outbox y publica al bus. La regla de oro es que solo marca un evento como enviado después de que el bus confirmó haberlo recibido:

// Acme/Sync/Relay.php — lee la outbox en orden y publica.
// Si publish() falla, NO se marca como enviado: el evento sigue ahí y se reintenta.
foreach ($this->outbox->unsent(batch: 100) as $event) {
    $this->bus->publish(
        topic: $event->type,
        payload: $event->payload,
        partitionKey: $event->aggregateId, // mismo SKU -> misma partición (ver agujero 2)
    );
    $this->outbox->markSent($event->id);
}

El peor caso ahora no es perder un evento: es enviarlo dos veces (si el relay muere justo entre el publish y el markSent). Y eso lo resolvemos en el agujero 3, a propósito. Cambiamos “puede perderse” por “puede duplicarse”, porque lo segundo se arregla y lo primero no.

Agujero 2: los eventos que llegan en desorden

Un bus de eventos no te garantiza que el consumidor reciba las cosas en el orden en que pasaron, salvo que se lo pidas explícitamente. Y para el inventario el orden importa: aplicar +5 reposición y después -1 venta da un resultado distinto que aplicarlos al revés si la venta llega cuando todavía no había stock.

Hay dos piezas que arreglan esto juntas:

  • Particionar por el agregado. Todos los eventos del mismo SKU tienen que ir a la misma partición del bus, usando el aggregate_id como clave (el partitionKey del relay de arriba). Así, dentro de un SKU, el orden se respeta. Entre SKUs distintos no importa, y de paso ganas paralelismo.
  • Numerar cada evento por agregado. Ese seq de la outbox es un contador por SKU. El consumidor lo usa para descartar lo que llega tarde: si ya aplicó el evento número 7, un número 5 que aparece después es un rezagado y se ignora.

Agujero 3: el evento que se procesa dos veces

Heredamos del agujero 1 un consumidor que puede recibir el mismo evento más de una vez, y del bus la realidad de que casi todos entregan “al menos una vez”. Así que el consumidor tiene que ser idempotente por diseño: procesar el mismo evento dos veces no puede cambiar el resultado.

La forma más sólida es registrar qué eventos ya viste y aplicar el cambio en la misma transacción, apoyándote en el seq del agujero 2 para ignorar además los rezagados:

-- El consumidor aplica el evento y registra que lo vio, atómicamente.
BEGIN;

-- 1) ¿Ya procesé este evento? El event_id es único; si ya está, no inserta nada.
INSERT INTO processed_events (event_id) VALUES ('e1f9...')
ON CONFLICT (event_id) DO NOTHING;
-- Si no se insertó ninguna fila, es un duplicado: COMMIT vacío y ack al bus. Fin.

-- 2) Aplica el cambio solo si este evento es más nuevo que el último visto del SKU.
UPDATE remote_inventory
   SET qty = qty + :delta,
       last_seq = :seq
 WHERE sku = :sku
   AND last_seq < :seq;  -- un rezagado (seq menor) no toca nada

COMMIT;

Con esto, el consumidor es inmune a los duplicados del relay, a los reintentos del bus y a los eventos que llegan tarde. El precio es una tabla de processed_events que hay que podar (un job que borra lo más viejo que la ventana de retención del bus), pero es un precio barato por dormir tranquilo.

En sistemas distribuidos no eliges entre “puede fallar” y “no puede fallar”. Eliges qué tipo de fallo prefieres, y los buenos diseños eligen el fallo que sí se puede reparar.

La red de seguridad: reconciliación, no esperanza

Hasta aquí el flujo en caliente es correcto. Pero “correcto en teoría” y “correcto durante dos años en producción” son cosas distintas, y la diferencia es asumir que algo, en algún momento, de todas formas se va a desincronizar: un bug en un consumidor nuevo, un evento mal formado, una migración a medias.

Por eso el diseño no termina en el flujo de eventos. Termina en un proceso de reconciliación que, cada cierto tiempo, compara el estado de los dos sistemas y corrige las diferencias. Suena al cron del principio, y la confusión es peligrosa, así que la diferencia importa:

  • El cron del principio era el mecanismo de sincronización: si fallaba, no había nada más.
  • La reconciliación es una red de seguridad sobre un flujo que ya es correcto. No mueve el grueso del trabajo; solo busca la deriva que no debería existir, la reporta y la corrige. Si encuentra mucho, eso es la alarma de que algo en el flujo de eventos está roto.

La reconciliación corrige el síntoma; las métricas, abajo, te dicen que vayas a arreglar la causa.

Detectar la deriva antes que el cliente

El objetivo final no es no fallar nunca. Es enterarte tú antes que el cliente. Tres señales valen más que cualquier dashboard bonito:

  • Lag del consumidor: cuántos eventos esperan en el bus sin procesar. Si sube y no baja, el destino se está quedando atrás y vas camino a vender aire.
  • Diferencias en cada reconciliación: cuántos SKUs corrigió el último pase. En régimen sano debería ser cero o casi. Un salto es la señal temprana de que un consumidor se rompió.
  • Edad del evento más viejo sin enviar en la outbox: si el relay se atascó, esto crece. Es lo primero que mira cuando “el stock no actualiza”.

Cuando estas tres están en un panel y con alerta, el ticket de “vendimos algo que no teníamos” deja de llegar por el lado del cliente y empieza a llegar por el lado del monitoreo, que es donde debe llegar.

Checklist: sincronización de inventario sin perder eventos

  1. Modela los cambios como eventos inmutables (stock.decremented, stock.replenished), no como un estado que se copia.
  2. Escribe el cambio y su evento en la misma transacción, en una tabla outbox. Nunca publiques al bus directo desde la lógica de negocio.
  3. Un relay aparte lee la outbox y publica; marca como enviado solo tras la confirmación del bus.
  4. Particiona por aggregate_id (el SKU) para garantizar orden dentro de cada agregado, y numera los eventos con un seq por agregado.
  5. Haz el consumidor idempotente: registra event_id procesados y aplica solo si el seq es más nuevo que el último visto.
  6. Agrega una reconciliación periódica como red de seguridad, no como el mecanismo principal.
  7. Mide lag del consumidor, diferencias por reconciliación y edad del evento más viejo en outbox. Alerta sobre las tres.

Cierre

El cron de quince minutos no estaba mal escrito. Estaba resolviendo el problema equivocado: trataba un proceso continuo y ordenado de hechos como si fuera una foto que se puede volver a tomar. Cuando el modelo mental pasó de “copiar el estado” a “transportar hechos en orden y una sola vez”, el inventario dejó de ser una fuente de tickets y el cruce manual de los lunes desapareció.

La tesis que me llevo, y que reaparece cada vez que dos sistemas tienen que coincidir: no preguntes cómo mantenerlos iguales, pregunta cómo contarle a uno, sin perder ni desordenar, lo que pasó en el otro.

Si tienes dos sistemas que insisten en no coincidir y ya estás cuadrándolos a mano, es el tipo de problema en el que trabajo. Puedes escribirme.