StackPractices
advanced Por Mathias Paulenko

Patrón Compensating Transaction

Deshace los efectos de una transacción completada ejecutando una operación contraria, habilitando consistencia eventual en procesos distribuidos de larga duración.

Descripción General

El Patrón Compensating Transaction deshace los efectos de una operación de negocio completada ejecutando una operación contraria semántica. A diferencia del rollback de base de datos (que deshace cambios no commiteados), las transacciones compensatorias deshacen operaciones que ya fueron commiteadas a sistemas externos — pagos que fueron cobrados, inventario que fue reservado, o emails que fueron enviados.

En sistemas distribuidos, las transacciones ACID a través de servicios son impracticables. El Patrón Saga coordina una secuencia de transacciones locales, y cuando un paso falla, las transacciones compensatorias revierten los pasos previamente completados. Esto permite que procesos de negocio de larga duración mantengan consistencia eventual sin locks distribuidos o two-phase commit.

Cuándo Usar

Usa el Patrón Compensating Transaction cuando:

  • Un proceso de negocio abarca múltiples servicios o bases de datos distribuidos
  • Necesitas deshacer operaciones que ya fueron commiteadas externamente
  • El two-phase commit (2PC) no está disponible o es impracticable (la mayoría de las arquitecturas de microservicios)
  • Los procesos de larga duración (segundos a días) necesitan semánticas de recuperación por fallas

Cuándo Evitar

  • La operación está dentro de una única base de datos y un simple rollback de transacción funciona
  • La lógica de compensación es imposible (ej. un email ya enviado a un cliente)
  • El proceso de negocio es tan corto que las transacciones distribuidas son aceptables
  • Las transacciones compensatorias mismas fallarían, creando un estado irrecuperable

Solución

Python

from dataclasses import dataclass
from typing import List, Callable, Optional
from datetime import datetime
import uuid

@dataclass
class StepResult:
    success: bool
    step_name: str
    compensation_needed: bool = False
    compensation_error: Optional[str] = None

class SagaOrchestrator:
    """Coordina una saga con transacciones compensatorias"""
    def __init__(self):
        self.completed_steps: List[dict] = []
        self.compensation_log: List[dict] = []

    def execute(self, steps: List[dict]) -> StepResult:
        """
        steps: lista de dicts con 'name', 'action', 'compensate'
        Cada uno es un callable que retorna booleano de éxito
        """
        for i, step in enumerate(steps):
            print(f"Ejecutando paso {i+1}: {step['name']}")
            success = step['action']()

            if success:
                self.completed_steps.append({
                    "index": i,
                    "name": step["name"],
                    "compensate": step["compensate"]
                })
            else:
                print(f"Paso {step['name']} falló! Ejecutando transacciones compensatorias...")
                self._compensate()
                return StepResult(success=False, step_name=step["name"])

        return StepResult(success=True, step_name="all_steps")

    def _compensate(self):
        """Ejecuta compensaciones en orden inverso"""
        for step in reversed(self.completed_steps):
            print(f"Compensando: {step['name']}")
            try:
                step["compensate"]()
                self.compensation_log.append({
                    "step": step["name"],
                    "status": "success",
                    "timestamp": datetime.now().isoformat()
                })
            except Exception as e:
                self.compensation_log.append({
                    "step": step["name"],
                    "status": "failed",
                    "error": str(e),
                    "timestamp": datetime.now().isoformat()
                })
                print(f"ADVERTENCIA: Compensación falló para {step['name']}: {e}")


# ============================================================================
# SERVICIOS DE DOMINIO CON COMPENSACIÓN
# ============================================================================

class PaymentService:
    def __init__(self):
        self.charges = {}

    def charge(self, order_id: str, amount: float) -> bool:
        txn_id = str(uuid.uuid4())
        self.charges[order_id] = {"txn_id": txn_id, "amount": amount, "status": "charged"}
        print(f"  [Payment] Cobrado ${amount} para orden {order_id}, txn={txn_id}")
        return True

    def refund(self, order_id: str) -> bool:
        charge = self.charges.get(order_id)
        if charge:
            charge["status"] = "refunded"
            print(f"  [Payment] Reembolsado ${charge['amount']} para orden {order_id}")
            return True
        print(f"  [Payment] No se encontró cargo para orden {order_id}")
        return False

class InventoryService:
    def __init__(self):
        self.stock = {"SKU-001": 100, "SKU-002": 50}
        self.reservations = {}

    def reserve(self, order_id: str, sku: str, qty: int) -> bool:
        if self.stock.get(sku, 0) >= qty:
            self.stock[sku] -= qty
            self.reservations[order_id] = {"sku": sku, "qty": qty}
            print(f"  [Inventory] Reservado {qty}x {sku} para orden {order_id}")
            return True
        print(f"  [Inventory] Stock insuficiente para {sku}")
        return False

    def release(self, order_id: str) -> bool:
        reservation = self.reservations.pop(order_id, None)
        if reservation:
            self.stock[reservation["sku"]] += reservation["qty"]
            print(f"  [Inventory] Liberado {reservation['qty']}x {reservation['sku']}")
            return True
        return False

class ShippingService:
    def __init__(self):
        self.shipments = {}

    def create_label(self, order_id: str, address: str) -> bool:
        self.shipments[order_id] = {"address": address, "status": "label_created"}
        print(f"  [Shipping] Etiqueta creada para orden {order_id}")
        return True

    def cancel_label(self, order_id: str) -> bool:
        shipment = self.shipments.pop(order_id, None)
        if shipment:
            print(f"  [Shipping] Etiqueta cancelada para orden {order_id}")
            return True
        return False


# ============================================================================
# DEFINICIÓN DE SAGA
# ============================================================================

class OrderSaga:
    def __init__(self, payments: PaymentService, inventory: InventoryService,
                 shipping: ShippingService):
        self.payments = payments
        self.inventory = inventory
        self.shipping = shipping

    def create_order(self, order_id: str, amount: float, sku: str, qty: int,
                     address: str) -> StepResult:
        saga = SagaOrchestrator()

        steps = [
            {
                "name": "charge_payment",
                "action": lambda: self.payments.charge(order_id, amount),
                "compensate": lambda: self.payments.refund(order_id)
            },
            {
                "name": "reserve_inventory",
                "action": lambda: self.inventory.reserve(order_id, sku, qty),
                "compensate": lambda: self.inventory.release(order_id)
            },
            {
                "name": "create_shipping_label",
                "action": lambda: self.shipping.create_label(order_id, address),
                "compensate": lambda: self.shipping.cancel_label(order_id)
            }
        ]

        return saga.execute(steps)


# ============================================================================
# USO
# ============================================================================

payments = PaymentService()
inventory = InventoryService()
shipping = ShippingService()

saga = OrderSaga(payments, inventory, shipping)

# Orden exitosa
print("=== ORDEN 1 (Éxito) ===")
result = saga.create_order("ORD-001", 99.99, "SKU-001", 2, "123 Main St")
print(f"Resultado: {'ÉXITO' if result.success else 'FALLIDO'}")

# Orden fallida (stock insuficiente dispara compensación)
print("\n=== ORDEN 2 (Fallo -> Compensación) ===")
result = saga.create_order("ORD-002", 999.99, "SKU-999", 500, "456 Oak Ave")
print(f"Resultado: {'ÉXITO' if result.success else 'FALLIDO'}")
print(f"Pago reembolsado: {payments.charges.get('ORD-002', {}).get('status')}")

Java

import java.util.*;
import java.util.function.*;

// Domain services
class PaymentService {
    private final Map<String, Map<String, Object>> charges = new HashMap<>();

    public boolean charge(String orderId, double amount) {
        Map<String, Object> charge = new HashMap<>();
        charge.put("amount", amount);
        charge.put("status", "charged");
        charges.put(orderId, charge);
        System.out.println("  [Payment] Cobrado $" + amount + " para " + orderId);
        return true;
    }

    public boolean refund(String orderId) {
        Map<String, Object> charge = charges.get(orderId);
        if (charge != null) {
            charge.put("status", "refunded");
            System.out.println("  [Payment] Reembolsado $" + charge.get("amount") + " para " + orderId);
            return true;
        }
        return false;
    }
}

class InventoryService {
    private final Map<String, Integer> stock = new HashMap<>(Map.of("SKU-001", 100));
    private final Map<String, Map<String, Object>> reservations = new HashMap<>();

    public boolean reserve(String orderId, String sku, int qty) {
        int available = stock.getOrDefault(sku, 0);
        if (available >= qty) {
            stock.put(sku, available - qty);
            Map<String, Object> res = new HashMap<>();
            res.put("sku", sku); res.put("qty", qty);
            reservations.put(orderId, res);
            System.out.println("  [Inventory] Reservado " + qty + "x " + sku);
            return true;
        }
        System.out.println("  [Inventory] Stock insuficiente para " + sku);
        return false;
    }

    public boolean release(String orderId) {
        Map<String, Object> res = reservations.remove(orderId);
        if (res != null) {
            String sku = (String) res.get("sku");
            int qty = (Integer) res.get("qty");
            stock.put(sku, stock.get(sku) + qty);
            System.out.println("  [Inventory] Liberado " + qty + "x " + sku);
            return true;
        }
        return false;
    }
}

// Saga step
class SagaStep {
    final String name;
    final Supplier<Boolean> action;
    final Runnable compensate;

    SagaStep(String name, Supplier<Boolean> action, Runnable compensate) {
        this.name = name; this.action = action; this.compensate = compensate;
    }
}

// Saga orchestrator
class SagaOrchestrator {
    private final List<SagaStep> completedSteps = new ArrayList<>();

    public boolean execute(List<SagaStep> steps) {
        for (SagaStep step : steps) {
            System.out.println("Ejecutando: " + step.name);
            if (step.action.get()) {
                completedSteps.add(step);
            } else {
                System.out.println(step.name + " falló! Compensando...");
                compensate();
                return false;
            }
        }
        return true;
    }

    private void compensate() {
        List<SagaStep> reverse = new ArrayList<>(completedSteps);
        Collections.reverse(reverse);
        for (SagaStep step : reverse) {
            System.out.println("Compensando: " + step.name);
            try {
                step.compensate.run();
            } catch (Exception e) {
                System.err.println("ADVERTENCIA: Compensación falló para " + step.name + ": " + e.getMessage());
            }
        }
    }
}

// Uso — pon esta clase en CompensatingTransactionDemo.java
public class CompensatingTransactionDemo {
    public static void main(String[] args) {
        PaymentService payments = new PaymentService();
        InventoryService inventory = new InventoryService();

        SagaOrchestrator saga = new SagaOrchestrator();
        List<SagaStep> steps = List.of(
            new SagaStep("charge", () -> payments.charge("ORD-001", 99.99), () -> payments.refund("ORD-001")),
            new SagaStep("reserve", () -> inventory.reserve("ORD-001", "SKU-001", 2), () -> inventory.release("ORD-001"))
        );

        boolean success = saga.execute(steps);
        System.out.println("Resultado de saga: " + (success ? "ÉXITO" : "FALLIDO"));
    }
}

JavaScript

class PaymentService {
  constructor() {
    this.charges = new Map();
  }

  charge(orderId, amount) {
    this.charges.set(orderId, { amount, status: 'charged' });
    console.log(`  [Payment] Cobrado $${amount} para ${orderId}`);
    return true;
  }

  refund(orderId) {
    const charge = this.charges.get(orderId);
    if (charge) {
      charge.status = 'refunded';
      console.log(`  [Payment] Reembolsado $${charge.amount} para ${orderId}`);
      return true;
    }
    return false;
  }
}

class InventoryService {
  constructor() {
    this.stock = new Map([['SKU-001', 100]]);
    this.reservations = new Map();
  }

  reserve(orderId, sku, qty) {
    const available = this.stock.get(sku) || 0;
    if (available >= qty) {
      this.stock.set(sku, available - qty);
      this.reservations.set(orderId, { sku, qty });
      console.log(`  [Inventory] Reservado ${qty}x ${sku}`);
      return true;
    }
    console.log(`  [Inventory] Stock insuficiente para ${sku}`);
    return false;
  }

  release(orderId) {
    const res = this.reservations.get(orderId);
    if (res) {
      this.stock.set(res.sku, this.stock.get(res.sku) + res.qty);
      console.log(`  [Inventory] Liberado ${res.qty}x ${res.sku}`);
      this.reservations.delete(orderId);
      return true;
    }
    return false;
  }
}

class SagaOrchestrator {
  constructor() {
    this.completedSteps = [];
  }

  async execute(steps) {
    for (const step of steps) {
      console.log(`Ejecutando: ${step.name}`);
      const success = await step.action();

      if (success) {
        this.completedSteps.push(step);
      } else {
        console.log(`${step.name} falló! Compensando...`);
        await this.compensate();
        return { success: false, failedStep: step.name };
      }
    }
    return { success: true };
  }

  async compensate() {
    const reverse = [...this.completedSteps].reverse();
    for (const step of reverse) {
      console.log(`Compensando: ${step.name}`);
      try {
        await step.compensate();
      } catch (e) {
        console.error(`ADVERTENCIA: Compensación falló para ${step.name}: ${e.message}`);
      }
    }
  }
}

// Uso
async function demo() {
  const payments = new PaymentService();
  const inventory = new InventoryService();
  const saga = new SagaOrchestrator();

  const steps = [
    {
      name: 'charge',
      action: () => payments.charge('ORD-001', 99.99),
      compensate: () => payments.refund('ORD-001')
    },
    {
      name: 'reserve',
      action: () => inventory.reserve('ORD-001', 'SKU-001', 2),
      compensate: () => inventory.release('ORD-001')
    }
  ];

  const result = await saga.execute(steps);
  console.log('Resultado:', result.success ? 'ÉXITO' : 'FALLIDO');
}

demo().catch(console.error);

Explicación

Una transacción compensatoria es un deshacer semántico en lugar de un rollback de base de datos:

  1. Cobrar pago → compensación es reembolsar pago
  2. Reservar inventario → compensación es liberar inventario
  3. Crear etiqueta de envío → compensación es cancelar etiqueta de envío

El orquestador de saga ejecuta pasos secuencialmente. Si algún paso falla, ejecuta compensaciones en orden inverso para todos los pasos previamente completados. Esto asegura que el sistema retorne a un estado consistente, aunque las operaciones individuales ya fueran commiteadas.

Saga con transacciones compensatorias — los pasos se ejecutan hacia adelante (cobrar, reservar, enviar); cuando el envío falla, las compensaciones corren en orden inverso: liberar inventario, luego reembolsar el pago

Propiedades clave:

  • Las compensaciones son operaciones de negocio en sí mismas, no comandos de base de datos
  • Las compensaciones pueden fallar (ej. un reembolso rechazado por el procesador de pagos) y deben ser monitoreadas
  • El log de saga registra lo que sucedió para auditoría e intervención manual

Lo que el código de producción añade sobre el orquestador de juguete de arriba: el log de saga vive en almacenamiento durable para que un orquestador que se cae pueda reanudar sagas en vuelo, y las compensaciones corren por un mecanismo de reintentos — una cola de compensación con backoff exponencial que escala a un humano cuando se agotan los reintentos. Un reembolso rechazado por el procesador de pagos no debería descartarse; debería despertar a alguien.

Variantes

VarianteCoordinaciónCaso de Uso
Saga OrquestadaCoordinador central maneja pasos y compensacionesWorkflows complejos con ordenamiento claro
Saga CoreografiadaServicios emiten eventos; listeners disparan siguientes pasos o compensacionesArquitecturas desacopladas, orientadas a eventos
Saga ParalelaPasos independientes corren concurrentemente; compensaciones corren para todos al fallarAlto throughput, pasos débilmente acoplados
Saga AnidadaUn paso de saga es en sí mismo una sub-saga con sus propias compensacionesProcesos de negocio recursivos

Lo que funciona

  • Diseña compensaciones desde el inicio. Son más difíciles de agregar retrospectivamente que las operaciones originales.
  • Haz compensaciones idempotentes. Pueden ser reintentadas si el primer intento falla.
  • Loggea todo. Estado de saga, resultados de compensación, y fallas deben ser observables.
  • Establece timeouts. Un paso que cuelga para siempre bloquea toda la saga.
  • Provee hooks para intervención manual. Algunas compensaciones requieren aprobación humana (ej. reembolsos sobre un umbral).

Errores Comunes

  • Asumir que las compensaciones siempre tienen éxito. Los reembolsos de pago pueden ser rechazados; el inventario puede ya haber sido enviado.
  • Faltar compensación para un paso. Cada paso de saga debe tener una contraparte definida.
  • Compensaciones no idempotentes. Ejecutar una compensación dos veces no debería duplicar el reembolso.
  • Perder estado de saga. Si el orquestador se cae, las sagas en vuelo deben ser recuperables desde un log persistente.
  • Ignorar fallas parciales. Un paso que “medio tiene éxito” (ej. pago cobrado pero no registrado) es el caso más difícil de compensar.

Ejemplos del Mundo Real

Procesamiento de Órdenes E-Commerce

Realizar una orden involucra pago, reserva de inventario, y envío. Si el envío falla después de que el pago tiene éxito, la saga compensa reembolsando el pago y liberando el inventario.

Reserva de Viajes

Reservar un viaje involucra vuelos, hoteles y renta de autos. Si la reserva de hotel falla después de que el vuelo fue reservado, la saga cancela la reserva de vuelo (si es posible) y reembolsa al cliente.

Transferencias Bancarias

Una saga de transferencia inter-bancaria debita la cuenta origen, inicia un wire, y acredita la cuenta destino. Si el wire falla, la saga acredita de vuelta la cuenta origen (compensando el débito).

Lectura Adicional

Preguntas frecuentes

¿Cuál es la diferencia entre transacción compensatoria y rollback de base de datos?

El rollback deshace cambios no commiteados dentro de una única transacción de base de datos — las filas nunca existieron. La compensación deshace cambios ya commiteados, entre sistemas que no comparten un transaction manager, ejecutando contra-operaciones. El registro existe; escribes un registro que lo compensa.

¿Pueden compensarse todas las operaciones?

No. Algunas operaciones son irreversibles: un email ya enviado, un paquete ya despachado, una operación bursátil ya ejecutada. Para esas necesitas otra estrategia — una ventana de cancelación antes de commitear el paso, una corrección posterior (email de retractación, etiqueta de devolución), o aceptar la inconsistencia y alertar. Diseña la saga para que los pasos irreversibles corran lo más tarde posible.

¿Cómo se relaciona esto con el Patrón Saga?

Saga es la estrategia de coordinación — la orquestación o coreografía que secuencia transacciones locales. Compensating Transaction es el mecanismo de deshacer que Saga usa cuando un paso falla. Puedes tener sagas sin compensación completa (algunos equipos prefieren forward recovery), pero no puedes revertir una saga distribuida sin transacciones compensatorias.

¿Qué pasa cuando una compensación misma falla?

Lo mismo que cualquier otro fallo: reintenta con backoff, pero no la dejes morir en silencio. Los sistemas de producción mandan las compensaciones fallidas a una cola dedicada que reintenta hasta que tiene éxito o escala a intervención manual — una compensación atascada significa estado inconsistente, que es exactamente lo que el patrón existe para prevenir. Monitorea el log de compensaciones como monitoreas el flujo principal.

¿Las compensaciones deberían correr síncronas o como trabajos en cola?

Depende de qué tan rápido necesita respuesta el usuario. La compensación dentro del request es más simple y mantiene el demo legible, pero en producción normalmente se encolan — el orquestador marca la saga como "compensando" y un worker drena la cola con reintentos. Así una API de reembolsos lenta no mantiene el request original como rehén, y una caída transitoria del procesador no pierde el rollback.

¿En qué se diferencia de simplemente capturar una excepción y deshacer cosas?

En el alcance. Un try/catch que deshace funciona dentro de un proceso y una conexión. Una transacción compensatoria es una contra-operación de negocio durable que sobrevive reinicios del proceso, funciona entre servicios, y aparece en tu auditoría — "orden ORD-002 compensada: inventario liberado, pago reembolsado" es un evento de primera clase, no el efecto secundario de un manejador de errores.