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.
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.
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_idcomo clave (elpartitionKeydel 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
seqde 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
- Modela los cambios como eventos inmutables (
stock.decremented,stock.replenished), no como un estado que se copia. - 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. - Un relay aparte lee la outbox y publica; marca como enviado solo tras la confirmación del bus.
- Particiona por
aggregate_id(el SKU) para garantizar orden dentro de cada agregado, y numera los eventos con unseqpor agregado. - Haz el consumidor idempotente: registra
event_idprocesados y aplica solo si elseqes más nuevo que el último visto. - Agrega una reconciliación periódica como red de seguridad, no como el mecanismo principal.
- 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.