Ir al contenido
wannacode

Una transacción entre servicios (Saga)

En resumen: Una Saga es una transacción entre varios servicios en forma de lista de pasos, cada uno con un paso de compensación; si algo falla, las compensaciones corren hacia atrás, empezando por el último paso hecho.

Problema

El dinero está cobrado, los audífonos ya no están

Un cliente pide los últimos audífonos por 59 € (pedido 1042). El pago se realiza. Un segundo después el almacén responde: «no hay en stock». Otro cliente se llevó la última unidad justo antes.

const payments = new Payments();
const warehouse = new Warehouse({ "audífonos": 0 });
const delivery = new Delivery();
const order = { id: 1042, item: "audífonos", price: 59, city: "Varna" };
try {
payments.charge(order); // el pago se realiza
warehouse.reserve(order); // el almacén: sin stock
delivery.book(order);
} catch (e) {
console.log(" ✗ " + e.message);
}
console.log("cobrado de la tarjeta: " + payments.charged + " €");
Salida
✓ cobrado 59 €
✗ no hay audífonos en stock
cobrado de la tarjeta: 59 €

El programa captura el error, pero el dinero queda cobrado. Mañana el cliente escribe a soporte, y alguien devuelve 59 € a mano.

En un monolito con una sola base de datos la respuesta es una transacción: todo o nada (la viste en la lección sobre bases de datos y en «ACID: todo o nada»). Aquí, sin embargo, el pago está en un proveedor externo, y el almacén es un servicio aparte con su propia base de datos. No hay una transacción que cubra los tres.

La idea

Una Saga: pasos con pasos inversos

Si no puedes volver atrás, haces acciones nuevas que arreglan las consecuencias. Cada paso del pedido viene con un paso inverso (una compensación):

Paso Servicio Paso inverso
cobra 59 € pagos devuelve 59 €
reserva los audífonos almacén los libera
contrata un mensajero entrega cancela al mensajero

Cada paso es una pequeña transacción local en su propio servicio. Si un paso rechaza, los pasos inversos de los pasos ya hechos corren hacia atrás: lo último que se hizo es lo primero que se deshace. Esto se llama Saga.

Un paso inverso no es un rollback. Un rollback borra, como si nada hubiera pasado. La devolución de 59 € es un segundo pago en sentido contrario, que el cliente ve en su banco junto al primero. Por eso, entre los pasos el sistema está en un estado intermedio («el pedido está en proceso»), y todos tienen que saber convivir con él.

Aquí la Saga la dirige un objeto, el orquestador: conoce los pasos, los llama en orden y, cuando algo falla, llama a las compensaciones. Los pasos están en una lista y las compensaciones en una pila: la última que entra es la primera que sale.

Míralo

Una Saga en código

El rechazo de un servicio es una excepción. En este sistema pequeño los servicios son objetos en un solo proceso, y los rechazos siguen un guion: hay un solo par de audífonos, y a Vidin no hay mensajero.

class StepFailed extends Error {}
const step = (action, undo) => ({ action, undo });
class Warehouse {
constructor(stock) {
this.stock = new Map(Object.entries(stock));
}
reserve(o) {
const left = this.stock.get(o.item) ?? 0;
if (left === 0) throw new StepFailed("no hay " + o.item + " en stock");
this.stock.set(o.item, left - 1);
console.log(" ✓ reservado: " + o.item);
}
release(o) {
this.stock.set(o.item, (this.stock.get(o.item) ?? 0) + 1);
console.log(" ↩ liberado: " + o.item);
}
}

JavaScript no tiene records ni interfaces: un paso es un objeto con dos funciones, action y undo. Los pagos y la entrega son como el almacén: un método para el paso y otro para el inverso.

El orquestador:

class Saga {
constructor(steps) {
this.steps = steps;
}
run(order) {
const undo = [];
try {
for (const step of this.steps) {
step.action(order);
undo.push(step.undo);
}
return "confirmado";
} catch (e) {
if (!(e instanceof StepFailed)) throw e;
console.log(" ✗ " + e.message);
while (undo.length > 0)
undo.pop()(order);
return "cancelado";
}
}
}
Paso a paso, en palabras
  1. Pedido 1043: un cargador por 25 € a Vidin. Cada paso vive en otro servicio, y cada uno tiene un inverso: cobrar ↔ devolver, reservar ↔ liberar, contratar ↔ cancelar.
  2. Paso 1: Payments cobra 25 €. Su paso inverso va a la pila undo (push).
  3. Paso 2: Warehouse reserva el cargador. «Liberar» se coloca sobre «devolver».
  4. Paso 3: no hay mensajero a Vidin, y Delivery rechaza. Los dos primeros pasos ya ocurrieron en otros sistemas, y no existe un rollback que los cubra.
  5. La Saga captura el rechazo y da marcha atrás: saca las compensaciones de la pila, la de arriba primero.
  6. pop entrega la última que entró: primero se libera el cargador. Lo último que se hizo es lo primero que se deshace.
  7. Luego el pago: 25 € vuelven a la tarjeta. Una devolución es una acción nueva, no un borrado: el cliente ve las dos en su banco.
  8. Cancelado: el cargador vuelve a estar en stock y quedan 0 € cobrados. Todos los sistemas vuelven a coincidir, con acciones posteriores en lugar de una sola transacción.

Tres pedidos: el 1041 pasa, el 1042 se detiene en el almacén, el 1043 se detiene en la entrega.

const payments = new Payments();
const warehouse = new Warehouse({ "audífonos": 1, "cargador": 5 });
const delivery = new Delivery();
const saga = new Saga([
step((o) => payments.charge(o), (o) => payments.refund(o)),
step((o) => warehouse.reserve(o), (o) => warehouse.release(o)),
step((o) => delivery.book(o), (o) => delivery.cancel(o)),
]);
const orders = [
{ id: 1041, item: "audífonos", price: 59, city: "Sofía" },
{ id: 1042, item: "audífonos", price: 59, city: "Varna" },
{ id: 1043, item: "cargador", price: 25, city: "Vidin" },
];
for (const order of orders) {
console.log("pedido " + order.id);
console.log(" = " + saga.run(order));
}
console.log("cobrado de las tarjetas: " + payments.charged + " €");
Salida
pedido 1041
✓ cobrado 59 €
✓ reservado: audífonos
✓ mensajero a Sofía
= confirmado
pedido 1042
✓ cobrado 59 €
✗ no hay audífonos en stock
↩ devuelto 59 €
= cancelado
pedido 1043
✓ cobrado 25 €
✓ reservado: cargador
✗ no hay mensajero a Vidin
↩ liberado: cargador
↩ devuelto 25 €
= cancelado
cobrado de las tarjetas: 59 €

Los métodos se pasan con una flecha, (o) => payments.charge(o): pasado solo, payments.charge pierde su this. Al final hay 59 € cobrados de las tarjetas: solo por el pedido que pasó.

Pruébalo

Predice la salida

La misma Saga, pero el almacén es el primer paso y el pago el segundo. Los audífonos se agotaron. ¿Qué imprime?

const payments = new Payments();
const warehouse = new Warehouse({ "audífonos": 0 });
const delivery = new Delivery();
const saga = new Saga([
step((o) => warehouse.reserve(o), (o) => warehouse.release(o)),
step((o) => payments.charge(o), (o) => payments.refund(o)),
step((o) => delivery.book(o), (o) => delivery.cancel(o)),
]);
console.log(" = " + saga.run({ id: 1042, item: "audífonos", price: 59, city: "Varna" }));
Respuesta

La idea

Lo que no se puede deshacer

No todo paso tiene un inverso. El correo «tu pedido está confirmado» no se puede mandar de vuelta. Un paquete que el mensajero ya recogió no regresa con una sola llamada.

Por eso esos pasos van al final, después de todo lo que puede rechazar. El paso a partir del cual la Saga solo avanza se llama pivot (punto de giro). Después de él, los fallos no se compensan, sino que se reintentan hasta que pasen: con timeout y retry.

Pruébalo

Encuentra el error

Un colega reordena el bucle y las pruebas pasan: cuando algo falla, el dinero se devuelve. Una semana después, contabilidad encuentra devoluciones de pagos que nunca ocurrieron: la tarjeta fue rechazada y el cliente recibió 59 €. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Orquestación o coreografía

El orquestador es un lugar que conoce todo el proceso. La otra forma no tiene ese lugar: cada servicio escucha eventos y responde con uno propio. Los pagos oyen «pedido» y dicen «cobrado». El almacén oye «cobrado» y dice «reservado» o «sin stock». Los pagos oyen «sin stock» y devuelven el dinero. Esto se llama coreografía.

El bus es una cola en memoria con suscripciones, como en la lección sobre eventos:

class Bus {
handlers = new Map();
queue = [];
on(type, handler) {
if (!this.handlers.has(type)) this.handlers.set(type, []);
this.handlers.get(type).push(handler);
}
publish(type, order) {
this.queue.push({ type, order });
}
run() {
while (this.queue.length > 0) {
const { type, order } = this.queue.shift();
console.log(type + " " + order);
for (const handler of this.handlers.get(type) ?? []) handler(order);
}
}
}

El mismo rechazo del 1042, como cadena de eventos:

const bus = new Bus();
// los pagos
bus.on("OrderPlaced", (order) => bus.publish("PaymentCharged", order));
bus.on("StockMissing", (order) => bus.publish("PaymentRefunded", order));
// el almacén (en este ejemplo la mercancía del 1042 se agotó)
bus.on("PaymentCharged", (order) => bus.publish("StockMissing", order));
// los pedidos
bus.on("PaymentRefunded", (order) => bus.publish("OrderCancelled", order));
bus.publish("OrderPlaced", 1042);
bus.run();
Salida
OrderPlaced 1042
PaymentCharged 1042
StockMissing 1042
PaymentRefunded 1042
OrderCancelled 1042
Orquestación Coreografía
Dónde está el proceso en un objeto repartido entre las suscripciones
Un paso nuevo una línea en la lista una suscripción nueva y un evento nuevo
«¿Por qué se detuvo el 1042?» el estado del orquestador reúnes los eventos de cada servicio
Acoplamiento el orquestador conoce a todos cada servicio conoce solo los eventos

Con dos o tres pasos, la coreografía es más ligera. Cuando hay cinco pasos con condiciones, el proceso se pierde y el orquestador es la opción más clara.

La idea

La compensación llega dos veces

Entre servicios, los comandos y los eventos pasan por una cola, y la cola entrega al menos una vez (lo viste en la lección sobre eventos). Si «devolver 59 € por el 1042» llega dos veces, el cliente recibe 118 €.

Por eso cada paso y cada compensación es idempotente: la segunda vez, con la misma entrada, no hace nada. La clave es la acción y el pedido. Hacia el proveedor real se envía como clave de idempotencia, para que también él rechace la devolución repetida.

class Refunds {
done = new Set();
total = 0;
refund(order, euros) {
// la clave del paso: acción + pedido
const key = "refund:" + order;
if (this.done.has(key)) {
console.log(" · " + order + ": ya devuelto");
return;
}
this.done.add(key);
this.total += euros;
console.log(" ↩ devuelto " + euros + " € por " + order);
}
}
const refunds = new Refunds();
// la cola entrega «al menos una vez»: el 1042 llega dos veces
refunds.refund(1042, 59);
refunds.refund(1042, 59);
refunds.refund(1043, 25);
console.log("devuelto en total: " + refunds.total + " €");
Salida
↩ devuelto 59 € por 1042
· 1042: ya devuelto
↩ devuelto 25 € por 1043
devuelto en total: 84 €

Pruébalo

Completa

¿Qué comprueba si la devolución ya se hizo?

const key = "refund:" + order;
if (this.done.(key)) {
  console.log("  · " + order + ": ya devuelto");
  return;
}
this.done.add(key);

La idea

Cuándo una Saga perjudica

Una Saga no es gratis:

  • El código se duplica. Cada paso necesita un inverso, pruebas para los dos y pruebas de fallo en cada punto.
  • Los estados intermedios se ven. El cliente ve «en proceso», y el dinero está cobrado un momento antes de volver. Otra persona puede leer un pedido que luego se cancela: una Saga no tiene aislamiento como una transacción.
  • Un fallo es más difícil de investigar. Los pasos están en servicios distintos, con registros distintos.

Por eso no la uses donde basta una transacción. Si el stock y los pedidos son módulos de un solo monolito con una base de datos, una transacción los protege de forma más simple y más segura. La Saga es para los pasos que salen del sistema: un proveedor externo, otro servicio, otra empresa.

Más precisamente, para curiosos

¿Por qué no dos fases (2PC)? Existe un protocolo para una transacción común entre varias bases de datos: el coordinador pregunta a todos «¿están listos?» y después les dice a todos «confirmen». El proveedor externo de pagos no participa en un protocolo así. Y mientras el coordinador espera, todos mantienen filas bloqueadas; si se cae a la mitad, se quedan bloqueadas. Por eso entre servicios casi siempre se usa una Saga.

El orquestador también se cae. Si el proceso se detiene después del pago, ¿cómo sabe hasta dónde llegó? Un orquestador real guarda el estado de cada Saga en una base de datos (paso 2 de 3, pedido 1042) y sigue tras un reinicio. Los comandos a los servicios salen por una outbox, para que no se pierdan entre la escritura y el envío.

Un bloqueo semántico. Un pedido «en proceso» es una señal para los demás: no lo mandes a facturar, no permitas cambiar la dirección. Así se compensa la falta de aislamiento.

Herramientas ya hechas: Temporal, Camunda y AWS Step Functions guardan el estado de procesos largos y reintentan los pasos. También hay bibliotecas de Saga en los frameworks: Axon (Java), MassTransit y NServiceBus (.NET). La cola entre servicios en un proyecto real es Kafka o RabbitMQ. Cómo se ponen en marcha y se vigilan está en el curso de DevOps ZeroToOps.

El nombre viene de un artículo de Hector Garcia-Molina y Kenneth Salem de 1987 sobre transacciones largas en bases de datos.

Ejercicio

Puntos de lealtad

Resuélvelo en tu editor.

La tienda da 1 punto por cada 10 € completos. Añade un servicio Loyalty con un método para el paso (da los puntos) y otro para el inverso (los quita). Ponlo en la Saga como tercer paso, entre el almacén y la entrega. Ejecuta el pedido 1044: un parlante por 120 € a Vidin, con 2 parlantes en el almacén. Al final imprime los puntos y el dinero cobrado.

Salida
pedido 1044
✓ cobrado 120 €
✓ reservado: parlante
✓ +12 puntos
✗ no hay mensajero a Vidin
↩ -12 puntos
↩ liberado: parlante
↩ devuelto 120 €
= cancelado
puntos: 0, cobrado: 0 €

Más tareas

  • Una tarjeta rechazada. Haz que el pago rechace por encima de 100 € («tarjeta rechazada») y ejecuta el 1044 a Sofía. ¿Qué hay en la pila cuando el pago rechaza?
  • Coreografía. Añade al bus los eventos StockReserved y OrderConfirmed y ejecuta un pedido para el que sí hay mercancía.

Test

Ponte a prueba

1. ¿Por qué el pedido no se puede proteger con una sola transacción de base de datos, como en el monolito?
2. Los pasos son pago, almacén, entrega. La entrega rechaza. ¿En qué orden corren las compensaciones?
3. ¿Por qué la compensación «devolver 59 € por el 1042» tiene que ser idempotente?
4. ¿Cuándo es mejor la coreografía (los servicios reaccionan a eventos) que la orquestación?
5. La tienda es un monolito con una base de datos: pedidos, stock y pagos en tablas distintas. El pago va a un proveedor externo. ¿Necesitas una Saga para el stock y el pedido?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·