La lección anterior terminó con la pregunta más incómoda del módulo. En el monolito de 01-06, crear un pedido era una transacción: BEGIN, insertar el pedido, descontar el stock, registrar el cobro, COMMIT; si cualquier paso fallaba, ROLLBACK y como si nada hubiera pasado. Hoy esa misma operación cruza tres servicios con tres bases de datos, km0_pedidos, km0_inventario y km0_pagos, cada una replicada como vimos en 03-04, y ninguna transacción de PostgreSQL abarca las tres. Si el cobro de Marc es rechazado después de haber descontado el último queso-curado, alguien tiene que devolverlo al stock; si pedidos muere entre el descuento y el cobro, el pedido de Marc queda en un limbo que nadie ha diseñado.

Esta lección presenta las dos familias de respuesta. La primera intenta conservar la atomicidad del monolito con un protocolo de commit en dos fases (2PC), que PostgreSQL soporta con PREPARE TRANSACTION; veremos cómo funciona, por qué bloquea cuando el coordinador cae y por qué los microservicios lo evitan. La segunda renuncia a la atomicidad y la sustituye por una saga: una secuencia de transacciones locales, cada una con su compensación, coordinada por eventos (coreografía) o por un orquestador con estado persistido. Implementaremos el orquestador de pedidos en Python, con su tabla sagas en km0_pedidos, y lo veremos compensar el stock cuando pagos rechaza a Marc; después escribiremos la misma saga como coreografía con consumidores Kafka y compararemos ambas. Terminaremos con lo que las sagas pierden respecto a ACID (el aislamiento) y las contramedidas, y con el patrón TCC. La conclusión cierra el Módulo 3 y abre el Módulo 4, dedicado a dónde y cómo se almacenan físicamente los datos que ya sabemos replicar y coordinar.

Contenido

  1. ACID y la transacción que ya no existe
  2. Commit en dos fases (2PC)
  3. Por qué 2PC bloquea, y 3PC
  4. 2PC real: XA y PREPARE TRANSACTION en PostgreSQL
  5. Por qué los microservicios evitan 2PC
  6. Sagas: transacciones locales con compensaciones
  7. Coreografía: la saga como cadena de eventos
  8. Orquestación: saga_pedido.py con estado persistido
  9. Tabla coreografía vs orquestación
  10. Compensaciones, idempotencia y el aislamiento perdido; TCC
  11. Errores comunes y consejos
  12. Ejercicios
  13. Conclusión

  1. ACID y la transacción que ya no existe

Recordemos qué prometía la transacción del monolito, porque cada letra de ACID va a tener un destino distinto en el sistema distribuido:

Propiedad Qué garantiza Qué le pasa al distribuir
Atomicidad Todos los pasos o ninguno Es lo que 2PC intenta conservar y lo que las sagas sustituyen por compensaciones
Consistencia (de ACID) Se respetan los invariantes del esquema (claves, restricciones) Sigue siendo local a cada base de datos; los invariantes entre servicios ("no hay pedido pagado sin stock reservado") pasan a ser responsabilidad de la aplicación
Aislamiento Las transacciones concurrentes no se ven a medias Se pierde entre servicios: otros ven los pasos intermedios de la saga (apartado 10)
Durabilidad Lo confirmado sobrevive a fallos La da cada base de datos por separado (con la replicación de 03-04)

La transacción original, en km0/sql/monolito.sql de 01-06, era esencialmente esta:

BEGIN;
INSERT INTO pedidos (id, cliente, estado, total) VALUES ('P-2026-000124', 'Marc', 'confirmado', 24.90);
UPDATE stock SET unidades = unidades - 1 WHERE producto = 'queso-curado' AND unidades >= 1;
INSERT INTO cobros (pedido_id, importe, estado) VALUES ('P-2026-000124', 24.90, 'cobrado');
COMMIT;

Si el UPDATE no afectaba a ninguna fila (sin stock) o la pasarela de pago fallaba, un ROLLBACK deshacía todo. Hoy las tres sentencias viven en tres servicios, y la pregunta es qué sustituye al COMMIT.

  1. Commit en dos fases (2PC)

El commit en dos fases (Gray, 1978) es el protocolo clásico de commit atómico: hacer que varios participantes, cada uno con su propia transacción local, confirmen todos o aborten todos. Sus roles:

  • Coordinador: el proceso que dirige el protocolo (en nuestro caso lo sería pedidos, o un gestor de transacciones externo).
  • Participantes: las bases de datos o servicios con una transacción local abierta (km0_pedidos, km0_inventario, km0_pagos).
sequenceDiagram
    participant C as Coordinador (pedidos)
    participant P as km0_pedidos
    participant I as km0_inventario
    participant G as km0_pagos
    Note over C,G: Fase 0: trabajo (transacciones locales abiertas)
    C->>P: INSERT pedido
    C->>I: UPDATE stock
    C->>G: INSERT cobro
    Note over C,G: Fase 1: PREPARE (votación)
    C->>P: prepare
    C->>I: prepare
    C->>G: prepare
    P-->>C: sí (escrito en disco, bloqueos retenidos)
    I-->>C: sí
    G-->>C: sí
    Note over C: Decisión: COMMIT, escrita en el log del coordinador
    Note over C,G: Fase 2: COMMIT
    C->>P: commit
    C->>I: commit
    C->>G: commit
    P-->>C: hecho
    I-->>C: hecho
    G-->>C: hecho

Fase 1, prepare. El coordinador pregunta a cada participante "¿puedes confirmar?". Un participante que responde se compromete de forma irrevocable: escribe la transacción en disco de manera que pueda confirmarla aunque se reinicie, y mantiene sus bloqueos hasta que llegue la decisión. Un participante puede responder no (violación de restricción, sin stock, tarjeta rechazada), y entonces la decisión será abortar.

Fase 2, commit o abort. Si todos han dicho sí, el coordinador escribe la decisión en su propio log (este es el punto de no retorno: a partir de aquí la transacción está confirmada aunque nadie lo sepa aún) y envía commit a todos; si alguno dijo no, envía abort. Los participantes ejecutan la decisión y responden; el coordinador puede reintentar la fase 2 tantas veces como haga falta, porque un participante en estado "preparado" siempre puede obedecer.

La corrección del protocolo se apoya en dos promesas: el participante preparado nunca decide por su cuenta, y el coordinador nunca olvida una decisión tomada. Ambas exigen escrituras en disco (fsync) en el momento adecuado, y por eso 2PC cuesta al menos dos viajes de red y dos fsync por participante, además del trabajo en sí.

  1. Por qué 2PC bloquea, y 3PC

El punto débil está en la palabra "nunca" de la primera promesa. Imagina que los tres participantes han respondido sí y el coordinador muere justo después, antes de enviar la decisión (o incluso antes de escribirla). Los participantes están en estado preparado: con los bloqueos retenidos (la fila de stock de queso-curado bloqueada, el pedido de Marc invisible para las lecturas que necesitan el bloqueo) y sin poder decidir nada:

  • No pueden abortar, porque quizá el coordinador había decidido commit y ya se lo había dicho a otro participante.
  • No pueden confirmar, porque quizá otro participante dijo no.
  • Preguntar a los otros participantes no siempre ayuda: si todos están preparados, ninguno sabe la decisión.

Solo pueden esperar a que el coordinador vuelva y lea su log. Si el coordinador tarda una hora, esa fila de stock está bloqueada una hora; es lo que se llama el bloqueo (blocking) de 2PC, y es un problema de disponibilidad en el sentido de 03-02: el sistema sacrifica A (participantes vivos que no pueden avanzar) para preservar la atomicidad. En la práctica, los operadores acaban resolviendo transacciones preparadas a mano (decidiendo commit o abort por inspección), lo que se llama heurística, con el riesgo evidente de decidir distinto de lo que decidió el coordinador.

El commit en tres fases (3PC, Skeen, 1981) añade una fase intermedia (pre-commit) para que los participantes puedan deducir la decisión sin el coordinador, y elimina el bloqueo... a costa de asumir una red síncrona con retrasos acotados y sin particiones, precisamente lo que 01-02 y 03-02 nos dijeron que no tenemos. Con una partición, 3PC puede llevar a un lado a confirmar y a otro a abortar. Por eso casi nadie lo usa, y por eso la solución moderna al bloqueo de 2PC es hacer al coordinador tolerante a fallos con consenso (03-03): Spanner, por ejemplo, ejecuta 2PC entre grupos Paxos, de modo que el "coordinador" es un grupo replicado que no muere. Es correcto y es carísimo.

  1. 2PC real: XA y PREPARE TRANSACTION en PostgreSQL

El estándar XA (X/Open, años 90) define la interfaz entre un gestor de transacciones y los recursos participantes (bases de datos, colas); Java lo expone como JTA, y los servidores de aplicaciones clásicos lo usaban para transacciones que abarcaban una base de datos y una cola JMS. PostgreSQL implementa el lado del participante con tres sentencias: PREPARE TRANSACTION 'id' (fase 1: la transacción actual queda preparada y sobrevive a reinicios), COMMIT PREPARED 'id' y ROLLBACK PREPARED 'id' (fase 2). Requiere max_prepared_transactions > 0 en postgresql.conf (por defecto es 0, deliberadamente). Un coordinador mínimo en Python entre km0_inventario y km0_pedidos:

# km0/simulaciones/dos_fases_pg.py
import psycopg          # pip install "psycopg[binary]"

DSN_PEDIDOS = "host=localhost port=5432 dbname=km0_pedidos user=km0 password=km0"
DSN_INVENTARIO = "host=localhost port=5434 dbname=km0_inventario user=km0 password=km0"
ID_TX = "pedido-P-2026-000124"           # identificador global de la transacción


def dos_fases(pedido_id: str, cliente: str, producto: str, total: float) -> None:
    ped = psycopg.connect(DSN_PEDIDOS, autocommit=True)
    inv = psycopg.connect(DSN_INVENTARIO, autocommit=True)
    participantes = [ped, inv]
    try:
        # Fase 0: trabajo en transacciones locales abiertas
        ped.execute("BEGIN")
        ped.execute("INSERT INTO pedidos (id, cliente, estado, total) VALUES (%s, %s, 'confirmado', %s)",
                    (pedido_id, cliente, total))
        inv.execute("BEGIN")
        afectadas = inv.execute("UPDATE stock SET unidades = unidades - 1 WHERE producto = %s AND unidades >= 1",
                                (producto,)).rowcount
        if afectadas == 0:
            raise RuntimeError(f"sin stock de {producto}")

        # Fase 1: prepare (cada participante vota; si falla, lanza excepción = voto "no")
        for conn in participantes:
            conn.execute(f"PREPARE TRANSACTION '{ID_TX}'")
        print("fase 1: todos preparados; la decisión es COMMIT")
        # >>> Si el coordinador muere AQUÍ, ambas bases de datos quedan preparadas y bloqueadas <<<

        # Fase 2: commit
        for conn in participantes:
            conn.execute(f"COMMIT PREPARED '{ID_TX}'")
        print("fase 2: confirmado en ambas")
    except Exception as e:
        print("abortando:", e)
        for conn in participantes:
            try:
                conn.execute(f"ROLLBACK PREPARED '{ID_TX}'")      # si llegó a prepararse
            except psycopg.Error:
                conn.execute("ROLLBACK")                           # si no llegó
    finally:
        ped.close(); inv.close()


if __name__ == "__main__":
    dos_fases("P-2026-000124", "Marc", "queso-curado", 24.90)

Para ver el bloqueo con tus propios ojos, mata el proceso (o pon un sys.exit()) justo después de la fase 1 y consulta en cualquiera de las bases de datos:

SELECT gid, prepared, owner, database FROM pg_prepared_xacts;
          gid           |           prepared            | owner |   database
------------------------+-------------------------------+-------+---------------
 pedido-P-2026-000124   | 2026-09-14 10:41:07.113+02    | km0   | km0_inventario

La transacción preparada sobrevive incluso a un reinicio del servidor, y mientras exista, la fila de queso-curado está bloqueada: cualquier otro UPDATE sobre ella espera indefinidamente. Solo un COMMIT PREPARED o ROLLBACK PREPARED explícito la libera, y decidir cuál es la resolución heurística del apartado 3. (Lo mismo que PREPARE TRANSACTION existe en MySQL con XA PREPARE, y en colas como ActiveMQ o IBM MQ; Kafka no participa en XA, y RabbitMQ tampoco, lo que ya limita mucho su uso con nuestra arquitectura.)

  1. Por qué los microservicios evitan 2PC

Con 2PC funcionando en dos líneas de SQL, la pregunta natural es por qué no usarlo para el pedido de Marc. Las razones son todas prácticas:

  1. Acoplamiento: el coordinador necesita acceso transaccional directo a las bases de datos de inventario y pagos, lo que rompe la regla "cada servicio es dueño de sus datos" de 01-06. La alternativa, que cada servicio exponga operaciones prepare/commit/abort por gRPC, es posible pero convierte a cada servicio en un gestor de recursos XA, con sus logs, su recuperación y sus transacciones huérfanas.
  2. Disponibilidad: el bloqueo del apartado 3 significa que la caída de pedidos (el coordinador) deja bloqueadas filas en inventario y pagos. Un fallo en un servicio se convierte en un fallo en tres: lo contrario del aislamiento de fallos que buscábamos al separar los servicios (01-03).
  3. Latencia y rendimiento: dos fases con fsync en cada participante, con los bloqueos retenidos durante todo el intercambio, limitan el número de pedidos por segundo a lo que tolere el participante más lento.
  4. Heterogeneidad: pagos habla con una pasarela externa (02-05) que no participa en ningún 2PC: no se puede "preparar" un cobro con tarjeta. Kafka y Redis tampoco son participantes XA. En cuanto un paso no es una base de datos relacional, 2PC ya no cubre la operación completa.
  5. Operación: las transacciones preparadas huérfanas requieren intervención manual, y los equipos que han operado XA en producción suelen describirlo como la fuente más frecuente de incidentes nocturnos.

2PC sigue siendo la herramienta correcta dentro de un sistema que lo controla todo (una base de datos distribuida como Spanner o CockroachDB lo usa entre sus propios nodos, con coordinador replicado por consenso), pero entre servicios independientes la industria ha convergido en la alternativa que renuncia a la atomicidad: la saga.

  1. Sagas: transacciones locales con compensaciones

El patrón saga (García-Molina y Salem, 1987, para transacciones de larga duración en una sola base de datos) se reformuló para microservicios hace una década. Una saga es una secuencia de transacciones locales T1, T2, ..., Tn, cada una en un servicio y confirmada por separado, tal que:

  • Si todas tienen éxito, la operación de negocio está completa.
  • Si Ti falla, se ejecutan las transacciones compensatorias C(i-1), ..., C1 de las que ya se habían confirmado, en orden inverso, dejando el sistema en un estado semánticamente equivalente al inicial (no idéntico: el pedido cancelado sigue existiendo como registro, el cobro reembolsado aparece en el extracto de Marc).

Para el pedido de Kilómetro Cero:

Paso Transacción local Servicio Compensación
T1 Crear pedido en estado pendiente pedidos C1: marcar pedido cancelado
T2 Reservar stock (ReservarStock de 02-03, con id_reserva) inventario C2: liberar la reserva
T3 Cobrar (con Idempotency-Key de 02-05) pagos C3: reembolsar
T4 Marcar pedido confirmado pedidos (ninguna: es el último paso)

Los pasos se clasifican en tres tipos, y el orden en que se colocan importa:

  • Compensables: tienen compensación (T1, T2). Van al principio.
  • Pivote: el paso que decide si la saga tendrá éxito; una vez confirmado, la saga no puede abortar (T3, el cobro: si se cobra, el pedido sale). Suele ser el paso con más probabilidad de fallar o el que no puede compensarse limpiamente, y se coloca lo más tarde posible.
  • Reintentables: después del pivote, pasos que no pueden fallar de forma permanente, solo transitoria, y se reintentan hasta que funcionen (T4).
sequenceDiagram
    participant Ped as pedidos
    participant Inv as inventario
    participant Pag as pagos
    Note over Ped,Pag: Saga feliz: el pedido de Ana
    Ped->>Ped: T1 crear P-2026-000123 (pendiente)
    Ped->>Inv: T2 ReservarStock(queso-curado, id_reserva)
    Inv-->>Ped: reservado
    Ped->>Pag: T3 cobrar(24,90 €, Idempotency-Key)
    Pag-->>Ped: cobrado
    Ped->>Ped: T4 marcar confirmado
sequenceDiagram
    participant Ped as pedidos
    participant Inv as inventario
    participant Pag as pagos
    Note over Ped,Pag: Saga con compensación: el pedido de Marc
    Ped->>Ped: T1 crear P-2026-000124 (pendiente)
    Ped->>Inv: T2 ReservarStock(queso-curado, id_reserva)
    Inv-->>Ped: reservado
    Ped->>Pag: T3 cobrar(24,90 €)
    Pag-->>Ped: RECHAZADO (tarjeta)
    Ped->>Inv: C2 LiberarReserva(id_reserva)
    Inv-->>Ped: liberada
    Ped->>Ped: C1 marcar cancelado (motivo: pago rechazado)

Hay dos formas de coordinar quién ejecuta cada paso y cada compensación, y son el objeto de los dos apartados siguientes.

  1. Coreografía: la saga como cadena de eventos

En una saga coreografiada no hay coordinador: cada servicio reacciona a los eventos de los demás y publica los suyos, por el tópico pedidos.eventos de 02-04 y 02-05 (o tópicos por servicio). La cadena de eventos del pedido:

flowchart LR
    A[pedidos:<br/>pedido.creado] --> B[inventario:<br/>stock.reservado]
    A --> B2[inventario:<br/>stock.insuficiente]
    B --> C[pagos:<br/>pago.confirmado]
    B --> C2[pagos:<br/>pago.rechazado]
    C --> D[pedidos:<br/>pedido.confirmado]
    C2 --> E[inventario:<br/>stock.liberado]
    B2 --> F[pedidos:<br/>pedido.cancelado]
    E --> F

Cada servicio implementa su parte como un consumidor idempotente de 02-05 que, en la misma transacción, aplica el efecto local y escribe el evento siguiente en su outbox. El fragmento de inventario, que reacciona a pedido.creado y a pago.rechazado:

# km0/servicios/inventario/saga_consumidor.py
import json
import uuid
import psycopg
from confluent_kafka import Consumer

DSN = "host=inventario-db dbname=km0_inventario user=km0 password=km0"
consumidor = Consumer({"bootstrap.servers": "kafka:9092", "group.id": "inventario-saga",
                       "enable.auto.commit": False, "auto.offset.reset": "earliest"})
consumidor.subscribe(["pedidos.eventos", "pagos.eventos"])


def publicar(cur, tipo: str, pedido_id: str, datos: dict) -> None:
    """Escribe en la outbox (02-05); el relay lo publicará en inventario.eventos."""
    cur.execute("INSERT INTO outbox (id, agregado, agregado_id, tipo, carga) VALUES (%s, 'pedido', %s, %s, %s)",
                (uuid.uuid4(), pedido_id, tipo, json.dumps({"pedido_id": pedido_id, **datos})))


def procesar(conn: psycopg.Connection, evento: dict) -> None:
    pedido_id = evento["datos"]["pedido_id"]
    with conn.transaction():
        cur = conn.cursor()
        cur.execute("INSERT INTO mensajes_procesados (id_mensaje, consumidor) VALUES (%s, 'inventario.saga') "
                    "ON CONFLICT DO NOTHING", (evento["id_evento"],))
        if cur.rowcount == 0:
            return                                                  # duplicado: ya procesado
        if evento["tipo"] == "pedido.creado":                       # T2: reservar
            ok = True
            for linea in evento["datos"]["lineas"]:
                cur.execute("UPDATE stock SET unidades = unidades - %s WHERE producto = %s AND unidades >= %s",
                            (linea["cantidad"], linea["producto"], linea["cantidad"]))
                ok = ok and cur.rowcount == 1
            if not ok:
                raise psycopg.Rollback                              # deshace los UPDATE parciales...
            cur.execute("INSERT INTO reservas (pedido_id, lineas) VALUES (%s, %s)",
                        (pedido_id, json.dumps(evento["datos"]["lineas"])))
            publicar(cur, "stock.reservado", pedido_id, {"lineas": evento["datos"]["lineas"]})
        elif evento["tipo"] == "pago.rechazado":                    # C2: liberar
            cur.execute("DELETE FROM reservas WHERE pedido_id = %s RETURNING lineas", (pedido_id,))
            fila = cur.fetchone()
            if fila:                                                # si no hay reserva, ya se liberó
                for linea in json.loads(fila[0]) if isinstance(fila[0], str) else fila[0]:
                    cur.execute("UPDATE stock SET unidades = unidades + %s WHERE producto = %s",
                                (linea["cantidad"], linea["producto"]))
            publicar(cur, "stock.liberado", pedido_id, {"motivo": "pago rechazado"})


def procesar_sin_stock(conn: psycopg.Connection, evento: dict) -> None:
    """Rama de fallo de T2: se publica en una transacción aparte, tras el rollback."""
    with conn.transaction():
        cur = conn.cursor()
        cur.execute("INSERT INTO mensajes_procesados (id_mensaje, consumidor) VALUES (%s, 'inventario.saga') "
                    "ON CONFLICT DO NOTHING", (evento["id_evento"],))
        publicar(cur, "stock.insuficiente", evento["datos"]["pedido_id"], {})


with psycopg.connect(DSN) as conn:
    while True:
        msg = consumidor.poll(1.0)
        if msg is None or msg.error():
            continue
        evento = json.loads(msg.value())
        try:
            procesar(conn, evento)
        except psycopg.Rollback:
            procesar_sin_stock(conn, evento)
        consumidor.commit(message=msg)

(psycopg.Rollback es la excepción que conn.transaction() captura para deshacer el bloque sin propagarla; aquí la reutilizamos como señal de "sin stock". En el mismo estilo, pagos consume stock.reservado, cobra con la Idempotency-Key de 02-05 y publica pago.confirmado o pago.rechazado; y pedidos consume pago.confirmado para T4 y stock.insuficiente/stock.liberado para C1.)

La coreografía es atractiva por su simplicidad aparente: no hay ningún componente nuevo, solo consumidores. Sus problemas aparecen al crecer: para saber en qué estado está el pedido de Marc hay que reconstruirlo a partir de eventos repartidos por tres tópicos; añadir un paso (por ejemplo, "asignar repartidor" entre el cobro y la confirmación) obliga a tocar varios servicios; y las dependencias cíclicas (pedidos escucha a pagos, que escucha a inventario, que escucha a pedidos) hacen difícil razonar sobre el flujo completo. Con tres pasos es manejable; con ocho, no.

  1. Orquestación: saga_pedido.py con estado persistido

En una saga orquestada, un componente, el orquestador, sabe cuál es la secuencia, invoca cada paso (por gRPC síncrono o publicando comandos), interpreta la respuesta y decide el siguiente paso o la compensación. El orquestador es una máquina de estados cuyo estado se persiste tras cada transición, de modo que si el proceso muere, otra instancia (o la misma al reiniciar) la retoma donde estaba. En Kilómetro Cero vive en pedidos, porque es el servicio dueño del concepto "pedido" y quien inicia la operación, con esta tabla en km0_pedidos:

-- km0/sql/pedidos/sagas.sql
CREATE TABLE sagas (
    id             UUID PRIMARY KEY,
    tipo           TEXT NOT NULL,                  -- 'pedido'
    pedido_id      TEXT NOT NULL UNIQUE,
    estado         TEXT NOT NULL,                  -- INICIADA, STOCK_RESERVADO, PAGO_CONFIRMADO, COMPLETADA,
                                                   -- COMPENSANDO, CANCELADA
    paso_actual    INTEGER NOT NULL DEFAULT 0,     -- último paso confirmado con éxito
    datos          JSONB NOT NULL,                 -- lo que necesitan los pasos y compensaciones
    historial      JSONB NOT NULL DEFAULT '[]',
    creada_en      TIMESTAMPTZ NOT NULL DEFAULT now(),
    actualizada_en TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX sagas_en_curso ON sagas (actualizada_en) WHERE estado NOT IN ('COMPLETADA', 'CANCELADA');

El orquestador define los pasos como pares (acción, compensación), avanza persistiendo, y compensa hacia atrás si un paso falla de forma permanente. Para que el ejemplo sea ejecutable sin infraestructura, los clientes de inventario y pagos y el repositorio de sagas tienen versiones simuladas; las reales serían el stub gRPC de 02-03, el cliente de la pasarela de 02-05 y psycopg sobre la tabla anterior.

# km0/servicios/pedidos/saga_pedido.py
import json
import uuid
from dataclasses import dataclass, field
from typing import Callable, Protocol


class FalloPermanente(Exception):
    """El paso no tendrá éxito aunque se reintente: hay que compensar."""


class FalloTransitorio(Exception):
    """El paso puede tener éxito si se reintenta (timeout, 503, deadline gRPC)."""


# --- Estado persistido -------------------------------------------------------
@dataclass
class Saga:
    id: str
    pedido_id: str
    estado: str = "INICIADA"
    paso_actual: int = 0
    datos: dict = field(default_factory=dict)
    historial: list = field(default_factory=list)


class Repositorio(Protocol):
    def guardar(self, saga: Saga) -> None: ...
    def cargar(self, saga_id: str) -> Saga: ...


class RepositorioMemoria:
    """Para la simulación. La versión real hace UPDATE sagas SET ... WHERE id = %s con psycopg."""
    def __init__(self) -> None:
        self.filas: dict[str, str] = {}
    def guardar(self, saga: Saga) -> None:
        self.filas[saga.id] = json.dumps(saga.__dict__)          # como haría JSONB
    def cargar(self, saga_id: str) -> Saga:
        return Saga(**json.loads(self.filas[saga_id]))


# --- Pasos -------------------------------------------------------------------
@dataclass
class Paso:
    nombre: str
    estado_tras_exito: str
    accion: Callable[[Saga], None]
    compensacion: Callable[[Saga], None] | None      # None = paso pivote o posterior


class OrquestadorSagaPedido:
    def __init__(self, repo: Repositorio, inventario, pagos, pedidos_db, max_reintentos: int = 3):
        self.repo, self.max_reintentos = repo, max_reintentos
        self.pasos = [
            Paso("reservar_stock", "STOCK_RESERVADO",
                 accion=lambda s: inventario.reservar(s.datos["id_reserva"], s.pedido_id, s.datos["lineas"]),
                 compensacion=lambda s: inventario.liberar(s.datos["id_reserva"])),
            Paso("cobrar", "PAGO_CONFIRMADO",
                 accion=lambda s: pagos.cobrar(s.datos["clave_idempotencia"], s.datos["cliente"], s.datos["total"]),
                 compensacion=lambda s: pagos.reembolsar(s.datos["clave_idempotencia"])),
            Paso("confirmar_pedido", "COMPLETADA",
                 accion=lambda s: pedidos_db.cambiar_estado(s.pedido_id, "confirmado"),
                 compensacion=None),
        ]
        self.pedidos_db = pedidos_db

    def _transicion(self, saga: Saga, estado: str, nota: str) -> None:
        saga.estado = estado
        saga.historial.append(f"{estado}: {nota}")
        self.repo.guardar(saga)                                   # persistir ANTES de seguir
        print(f"  [saga {saga.pedido_id}] -> {estado} ({nota})")

    def iniciar(self, pedido_id: str, cliente: str, lineas: list[dict], total: float) -> Saga:
        saga = Saga(id=str(uuid.uuid4()), pedido_id=pedido_id,
                    datos={"cliente": cliente, "lineas": lineas, "total": total,
                           "id_reserva": f"res-{pedido_id}", "clave_idempotencia": f"cobro-{pedido_id}"})
        self.pedidos_db.crear(pedido_id, cliente, total)           # T1, en la MISMA transacción que la saga
        self._transicion(saga, "INICIADA", "pedido creado en estado pendiente")
        return self.continuar(saga.id)

    def continuar(self, saga_id: str) -> Saga:
        """Avanza (o compensa) desde el estado persistido. Reentrante: se puede llamar tras un reinicio."""
        saga = self.repo.cargar(saga_id)
        if saga.estado == "COMPENSANDO":
            return self._compensar(saga)
        while saga.paso_actual < len(self.pasos):
            paso = self.pasos[saga.paso_actual]
            for intento in range(1, self.max_reintentos + 1):
                try:
                    paso.accion(saga)
                    break
                except FalloTransitorio as e:
                    print(f"  [saga {saga.pedido_id}] {paso.nombre}: fallo transitorio ({e}), intento {intento}")
                    if intento == self.max_reintentos and paso.compensacion is None:
                        return saga            # paso reintentable: dejarlo para un proceso periódico
                except FalloPermanente as e:
                    self._transicion(saga, "COMPENSANDO", f"{paso.nombre} falló: {e}")
                    return self._compensar(saga)
            else:
                self._transicion(saga, "COMPENSANDO", f"{paso.nombre} agotó los reintentos")
                return self._compensar(saga)
            saga.paso_actual += 1
            self._transicion(saga, paso.estado_tras_exito, f"{paso.nombre} ok")
        return saga

    def _compensar(self, saga: Saga) -> Saga:
        while saga.paso_actual > 0:
            paso = self.pasos[saga.paso_actual - 1]
            if paso.compensacion is not None:
                paso.compensacion(saga)                           # debe ser idempotente
                print(f"  [saga {saga.pedido_id}]    compensado {paso.nombre}")
            saga.paso_actual -= 1
            self.repo.guardar(saga)
        self.pedidos_db.cambiar_estado(saga.pedido_id, "cancelado")   # C1
        self._transicion(saga, "CANCELADA", "todas las compensaciones aplicadas")
        return saga


# --- Servicios simulados -------------------------------------------------------
class InventarioSimulado:
    def __init__(self) -> None:
        self.stock = {"queso-curado": 1, "vino-crianza": 12}
        self.reservas: dict[str, list[dict]] = {}
    def reservar(self, id_reserva: str, pedido_id: str, lineas: list[dict]) -> None:
        if id_reserva in self.reservas:
            return                                                # idempotente (02-03)
        for l in lineas:
            if self.stock[l["producto"]] < l["cantidad"]:
                raise FalloPermanente(f"sin stock de {l['producto']}")
        for l in lineas:
            self.stock[l["producto"]] -= l["cantidad"]
        self.reservas[id_reserva] = lineas
        print(f"    inventario: reservado {lineas} -> stock {self.stock}")
    def liberar(self, id_reserva: str) -> None:
        lineas = self.reservas.pop(id_reserva, None)             # idempotente: si no existe, nada
        if lineas:
            for l in lineas:
                self.stock[l["producto"]] += l["cantidad"]
            print(f"    inventario: liberada {id_reserva} -> stock {self.stock}")


class PagosSimulado:
    def __init__(self) -> None:
        self.cobros: dict[str, float] = {}
        self.tarjetas_rechazadas = {"Marc"}
    def cobrar(self, clave: str, cliente: str, importe: float) -> None:
        if clave in self.cobros:
            return                                                # idempotente (02-05)
        if cliente in self.tarjetas_rechazadas:
            raise FalloPermanente("tarjeta rechazada por el emisor")
        self.cobros[clave] = importe
        print(f"    pagos: cobrados {importe:.2f} EUR a {cliente}")
    def reembolsar(self, clave: str) -> None:
        if clave in self.cobros:
            print(f"    pagos: reembolsados {self.cobros.pop(clave):.2f} EUR")


class PedidosDbSimulada:
    def __init__(self) -> None:
        self.pedidos: dict[str, dict] = {}
    def crear(self, pedido_id: str, cliente: str, total: float) -> None:
        self.pedidos[pedido_id] = {"cliente": cliente, "total": total, "estado": "pendiente"}
    def cambiar_estado(self, pedido_id: str, estado: str) -> None:
        self.pedidos[pedido_id]["estado"] = estado


if __name__ == "__main__":
    inventario, pagos, pedidos_db = InventarioSimulado(), PagosSimulado(), PedidosDbSimulada()
    orq = OrquestadorSagaPedido(RepositorioMemoria(), inventario, pagos, pedidos_db)

    print("Saga 1: Ana compra el último queso curado")
    orq.iniciar("P-2026-000123", "Ana", [{"producto": "queso-curado", "cantidad": 1}], 24.90)

    print("\nSaga 2: Marc intenta comprar queso curado (ya no hay)")
    orq.iniciar("P-2026-000124", "Marc", [{"producto": "queso-curado", "cantidad": 1}], 24.90)

    print("\nSaga 3: Marc compra vino crianza, pero su tarjeta es rechazada")
    orq.iniciar("P-2026-000125", "Marc", [{"producto": "vino-crianza", "cantidad": 2}], 31.80)

    print("\nEstado final de los pedidos:")
    for pid, p in pedidos_db.pedidos.items():
        print(f"  {pid}: {p['estado']}")
    print("stock final:", inventario.stock, "| cobros:", pagos.cobros)

Salida:

Saga 1: Ana compra el último queso curado
  [saga P-2026-000123] -> INICIADA (pedido creado en estado pendiente)
    inventario: reservado [{'producto': 'queso-curado', 'cantidad': 1}] -> stock {'queso-curado': 0, 'vino-crianza': 12}
  [saga P-2026-000123] -> STOCK_RESERVADO (reservar_stock ok)
    pagos: cobrados 24.90 EUR a Ana
  [saga P-2026-000123] -> PAGO_CONFIRMADO (cobrar ok)
  [saga P-2026-000123] -> COMPLETADA (confirmar_pedido ok)

Saga 2: Marc intenta comprar queso curado (ya no hay)
  [saga P-2026-000124] -> INICIADA (pedido creado en estado pendiente)
  [saga P-2026-000124] -> COMPENSANDO (reservar_stock falló: sin stock de queso-curado)
  [saga P-2026-000124] -> CANCELADA (todas las compensaciones aplicadas)

Saga 3: Marc compra vino crianza, pero su tarjeta es rechazada
  [saga P-2026-000125] -> INICIADA (pedido creado en estado pendiente)
    inventario: reservado [{'producto': 'vino-crianza', 'cantidad': 2}] -> stock {'queso-curado': 0, 'vino-crianza': 10}
  [saga P-2026-000125] -> STOCK_RESERVADO (reservar_stock ok)
  [saga P-2026-000125] -> COMPENSANDO (cobrar falló: tarjeta rechazada por el emisor)
    inventario: liberada res-P-2026-000125 -> stock {'queso-curado': 0, 'vino-crianza': 12}
  [saga P-2026-000125]    compensado reservar_stock
  [saga P-2026-000125] -> CANCELADA (todas las compensaciones aplicadas)

Estado final de los pedidos:
  P-2026-000123: confirmado
  P-2026-000124: cancelado
  P-2026-000125: cancelado
stock final: {'queso-curado': 0, 'vino-crianza': 12} | cobros: {'cobro-P-2026-000123': 24.9}

Los puntos importantes del código, para quien lo lea por primera vez:

  • Persistir antes de seguir. _transicion guarda la saga en cada cambio de estado, y paso_actual se incrementa solo tras el éxito del paso. Si el proceso muere entre paso.accion(saga) y _transicion, al reiniciar continuar(saga_id) volverá a ejecutar el mismo paso, y por eso cada acción debe ser idempotente: reservar con el mismo id_reserva no descuenta dos veces (02-03), cobrar con la misma clave_idempotencia no cobra dos veces (02-05). La saga hereda directamente el trabajo del Módulo 2.
  • Los identificadores se deciden al inicio. id_reserva y clave_idempotencia se derivan del pedido_id y se guardan en datos en la primera transición, para que un reintento tras un reinicio use exactamente los mismos.
  • Fallo permanente frente a transitorio. Un FalloPermanente (sin stock, tarjeta rechazada) dispara la compensación; un FalloTransitorio (deadline gRPC, 503 de la pasarela) se reintenta, y si es en un paso posterior al pivote se deja para un proceso periódico que llame a continuar sobre las sagas en curso (el índice sagas_en_curso existe para eso).
  • Las compensaciones también son idempotentes (liberar de una reserva inexistente no hace nada) y se ejecutan en orden inverso, sin tocar los pasos que no llegaron a ejecutarse (en la saga 2, no hay nada que liberar).
  • T1 y la saga en la misma transacción. En la versión real, pedidos_db.crear y el primer guardar de la saga van en una única transacción de km0_pedidos (y, si la saga se conduce por eventos, el primer comando va a la outbox en esa misma transacción). Así no puede existir un pedido pendiente sin saga ni una saga sin pedido.

En las tres sagas simuladas, el resultado es el que el monolito habría dado con ROLLBACK: Ana tiene su queso y su cobro; Marc no tiene ni queso ni vino, no se le ha cobrado nada, y el stock de vino ha vuelto a 12. La diferencia es que ha ocurrido en pasos separados, visibles desde fuera, y que los pedidos cancelados existen como registro.

  1. Tabla coreografía vs orquestación

Aspecto Coreografía Orquestación
Quién sabe la secuencia Nadie en concreto: está repartida en los consumidores El orquestador
Componentes nuevos Ninguno (consumidores + outbox) El orquestador y su tabla de estado
Acoplamiento Bajo entre servicios, pero cada uno conoce los eventos de los demás Los servicios solo conocen sus comandos; el orquestador los conoce a todos
Saber en qué estado está el pedido de Marc Reconstruir a partir de eventos en varios tópicos SELECT estado FROM sagas WHERE pedido_id = ...
Añadir un paso Tocar varios servicios Tocar el orquestador (y el servicio nuevo)
Dependencias cíclicas Fáciles de crear sin querer Imposibles: el orquestador es el único que llama
Riesgo de "dios" No El orquestador puede acumular lógica de negocio que no le corresponde
Pruebas Integración con broker El orquestador se prueba en aislamiento con dobles (como en la simulación)
Recomendación habitual Sagas de 2-3 pasos, flujos estables Sagas de 4+ pasos, flujos que cambian, necesidad de consultar el estado
Herramientas Kafka/RabbitMQ + outbox Temporal, Camunda/Zeebe, AWS Step Functions, o un orquestador propio como el del apartado 8

En Kilómetro Cero, la saga del pedido tiene tres pasos hoy pero crecerá (asignación de repartidor, aviso al productor, cupones de campaña), y el equipo de soporte necesita saber en qué estado está cada pedido; por eso la elección es la orquestación en pedidos. La coreografía se reserva para reacciones simples y estables, como que analitica consuma pedido.confirmado.

  1. Compensaciones, idempotencia y el aislamiento perdido; TCC

Compensar no es deshacer

Una compensación es una nueva transacción de negocio con efectos propios, no un ROLLBACK: el reembolso aparece en el extracto de Marc, la reserva liberada genera un stock.actualizado que catalogo mostrará, el pedido cancelado se queda en el historial con su motivo. Algunas acciones no tienen compensación posible (un correo enviado, un repartidor que ya salió) y deben colocarse después del pivote. Y toda compensación debe ser idempotente y no puede fallar de forma permanente: si reembolsar devuelve un error definitivo, la saga queda en un estado que exige intervención humana, y hay que diseñar para ello (un estado COMPENSACION_FALLIDA con alerta, 07-01).

El aislamiento perdido

Las sagas renuncian a la I de ACID, y eso produce anomalías concretas entre T1 y T4:

Anomalía Ejemplo en el pedido Contramedida
Actualizaciones perdidas Mientras la saga de Marc está en STOCK_RESERVADO, otra saga cancela el pedido y libera; luego la primera lo confirma Bloqueo semántico: marcar el pedido como pendiente (o en_saga) y rechazar otras operaciones sobre él hasta que la saga acabe; es lo que hace T1
Lecturas sucias catalogo muestra el stock descontado por T2 antes de que T3 decida, y luego vuelve a subir Estados "pendiente de confirmar": distinguir stock reservado de stock vendido; el catálogo muestra disponible = unidades - reservado y sabe que es provisional
Lecturas no repetibles / fuzzy analitica suma las ventas del día y cuenta el pedido de Marc antes de que se cancele Lectura pesimista: solo contar pedidos en estado confirmado; o reordenar la saga para que el paso más volátil vaya primero (reordenación)
Decisiones sobre datos intermedios Un cupón de "Semana del Queso Artesano" se aplica a un pedido que luego se cancela, y el contador de cupones no se restaura Valor conmutativo: usar operaciones que se puedan compensar exactamente (incrementar/decrementar, como el PNCounter de 03-01) en lugar de asignaciones

Estas contramedidas (bloqueo semántico, lecturas pesimistas, reordenación, valores conmutativos, y "versión de archivo" para conservar el estado previo) las catalogó Chris Richardson a partir del artículo original de García-Molina, y la mayoría se reducen a una idea: hacer visible que el estado es provisional en lugar de fingir aislamiento.

TCC: Try-Confirm/Cancel

Una variante de la saga que recupera algo de aislamiento es TCC (Try-Confirm/Cancel): cada paso se divide en try (reservar el recurso de forma provisional, sin consumirlo), confirm (consumirlo definitivamente) y cancel (liberar la reserva). El orquestador ejecuta todos los try, y solo si todos tienen éxito ejecuta los confirm; en caso contrario, los cancel. Es 2PC a nivel de negocio, sin bloqueos de base de datos: inventario con ReservarStock y ConfirmarReserva/LiberarReserva ya es, de hecho, un recurso TCC, y la pasarela de pago con pre-autorización y captura, también. El precio es que cada servicio tiene que implementar las tres operaciones y gestionar reservas que caducan (un try sin confirm ni cancel porque el orquestador murió), lo que hace de TCC la elección cuando los recursos son escasos y disputados (la última unidad, una plaza, un asiento) y de la saga simple la elección para todo lo demás.

Errores Comunes y Consejos

  • Usar 2PC entre microservicios "porque es lo que hace la base de datos". Acopla, bloquea ante la caída del coordinador y no cubre pasarelas ni brokers. Reserva 2PC para el interior de un sistema que lo controla todo.
  • Sagas con pasos no idempotentes. Tras un reinicio, el orquestador reejecuta el último paso. Sin id_reserva, sin clave de idempotencia, el stock se descuenta dos veces y el cliente paga dos veces. Los identificadores de idempotencia se generan al iniciar la saga y se persisten con ella.
  • Compensaciones que pueden fallar sin plan. Diseña el estado de "compensación fallida", con alerta y procedimiento manual, antes de que ocurra en producción.
  • El pivote en el sitio equivocado. Si el paso que más falla (el cobro) va primero, se compensa poco; si va último, se compensa todo lo anterior. Ordena: compensables, después el pivote, después los reintentables.
  • Fingir aislamiento. Mostrar el stock descontado por una saga en curso como definitivo, o contar pedidos pendientes como ventas, produce los errores de la tabla del apartado 10. Modela explícitamente los estados provisionales.
  • Orquestador con lógica de negocio de otros servicios. El orquestador decide el orden y las compensaciones; no decide si hay stock ni si la tarjeta es válida. Si empieza a hacerlo, se ha convertido en un monolito distribuido.
  • Sagas sin timeout. Una saga en STOCK_RESERVADO durante horas porque pagos no responde retiene stock que otros querrían comprar. Define un plazo máximo por paso y por saga, tras el cual se compensa.
  • Consejo: guarda el historial de la saga. Cuando un cliente pregunte por qué se canceló su pedido, "cobrar falló: tarjeta rechazada por el emisor" en la fila de sagas vale más que cualquier log.
  • Consejo: prueba el orquestador con dobles que fallen en cada paso, de forma permanente y transitoria, y tras un "reinicio" (llamar a continuar con el estado guardado). Es el tipo de prueba que en 07-06 automatizaremos con ingeniería del caos.

Ejercicios

Ejercicio 1: Reinicio a mitad de saga

Con la simulación del apartado 8, modela una caída del orquestador justo después de que inventario reserve el vino de Lucía (P-2026-000126, 2 unidades de vino-crianza, tarjeta válida) pero antes de la transición a STOCK_RESERVADO. Hazlo con un InventarioSimulado cuya reservar lance una excepción SystemExit la primera vez, tras haber reservado. Después crea un nuevo OrquestadorSagaPedido con el mismo RepositorioMemoria, los mismos servicios simulados, y llama a continuar(saga.id). ¿Qué pasos se reejecutan? ¿Se descuenta el stock dos veces? ¿Qué cambiarías en la implementación para que iniciar devolviera el saga.id aunque el proceso muera a mitad?

Ejercicio 2: Añadir un paso a las dos versiones

Kilómetro Cero quiere añadir "asignar repartidor" (reparto.asignar(pedido_id, ciudad), compensación reparto.desasignar(pedido_id), puede fallar de forma permanente si no hay repartidores en la ciudad) entre el cobro y la confirmación. Describe qué hay que cambiar en la orquestación (apartado 8) y en la coreografía (apartado 7): qué ficheros, qué eventos nuevos, qué consumidores. ¿Es correcto colocarlo después del cobro? ¿Qué implicaría para Marc, cuya tarjeta sí es válida esta vez, un fallo permanente en ese paso?

Ejercicio 3: Elegir 2PC, saga o TCC

Para cada operación, elige 2PC, saga coreografiada, saga orquestada o TCC, y justifica en dos o tres frases:

  1. Trasladar 50 unidades de tomate-rosa entre las tablas stock de inv-bcn e inv-vlc (ambas son PostgreSQL del mismo servicio inventario, sin pasarelas externas).
  2. Registrar una entrega: reparto marca el pedido entregado, pedidos cambia el estado, analitica actualiza el tiempo medio de entrega, y se envía un correo a Ana.
  3. Reservar las últimas 3 botellas de vino-crianza para un pedido de Lucía y cobrarlas con una tarjeta que requiere autenticación reforzada (el cliente puede tardar minutos en confirmarla en la app del banco).

Soluciones

Solución 1:

class InventarioQueMuere(InventarioSimulado):
    def __init__(self) -> None:
        super().__init__(); self.ya_murio = False
    def reservar(self, id_reserva, pedido_id, lineas):
        super().reservar(id_reserva, pedido_id, lineas)          # la reserva SÍ se hace
        if not self.ya_murio:
            self.ya_murio = True
            raise SystemExit("el proceso de pedidos muere aquí")

repo, inventario, pagos, pedidos_db = RepositorioMemoria(), InventarioQueMuere(), PagosSimulado(), PedidosDbSimulada()
try:
    OrquestadorSagaPedido(repo, inventario, pagos, pedidos_db).iniciar(
        "P-2026-000126", "Lucía", [{"producto": "vino-crianza", "cantidad": 2}], 31.80)
except SystemExit as e:
    print("!!!", e)
saga_id = next(iter(repo.filas))                                  # en la realidad: SELECT ... WHERE estado NOT IN (...)
orq2 = OrquestadorSagaPedido(repo, inventario, pagos, pedidos_db)  # "nueva instancia" tras el reinicio
orq2.continuar(saga_id)
print(inventario.stock, pedidos_db.pedidos["P-2026-000126"]["estado"])

La saga quedó persistida en INICIADA con paso_actual = 0, así que continuar reejecuta reservar_stock. Como InventarioSimulado.reservar es idempotente por id_reserva (res-P-2026-000126 ya está en reservas), no descuenta dos veces: el stock queda en 10, se cobra a Lucía y la saga llega a COMPLETADA. Si quitas la comprobación if id_reserva in self.reservas, verás el stock en 8: es exactamente el fallo que la idempotencia de 02-03 evita. Sobre iniciar: en la implementación real no devuelve nada a nadie si el proceso muere; lo importante es que la saga esté en la tabla, y un proceso periódico (SELECT id FROM sagas WHERE estado NOT IN ('COMPLETADA','CANCELADA') AND actualizada_en < now() - interval '1 minute') llame a continuar sobre las sagas huérfanas. Además, iniciar debería crear el pedido y la saga en una sola transacción y responder al cliente "pedido recibido, pendiente" antes de ejecutar los pasos, que pueden tardar.

Solución 2:

Orquestación: un solo cambio en saga_pedido.py: insertar un Paso("asignar_repartidor", "REPARTIDOR_ASIGNADO", accion=reparto.asignar(...), compensacion=reparto.desasignar(...)) en la lista self.pasos entre cobrar y confirmar_pedido, añadir ciudad a datos, y admitir el nuevo estado en la tabla sagas. Los servicios inventario y pagos no cambian. Coreografía: reparto necesita un consumidor nuevo de pago.confirmado que publique repartidor.asignado o reparto.imposible; pedidos deja de confirmar al recibir pago.confirmado y pasa a hacerlo con repartidor.asignado; pago.rechazado sigue igual, pero reparto.imposible debe disparar dos compensaciones: pagos debe consumirlo y reembolsar (nuevo consumidor) y luego inventario debe liberar al recibir pago.reembolsado (evento nuevo y consumidor nuevo). Tres servicios tocados, dos eventos nuevos, y la cadena de compensación se alarga.

¿Después del cobro? Si "asignar repartidor" puede fallar de forma permanente, colocarlo después del pivote (cobro) obliga a reembolsar, que es la compensación más visible y molesta para el cliente (Marc vería un cargo y un abono en su extracto). Sería mejor colocarlo antes del cobro (reservar repartidor es compensable y barato de deshacer), convirtiendo el cobro de nuevo en el último paso compensable-o-pivote. Regla general: los pasos que pueden fallar permanentemente van antes del pivote.

Solución 3:

  1. 2PC (o, mejor, una única transacción): ambas tablas pertenecen al mismo servicio, inventario, así que no hay acoplamiento indebido; si están en la misma base de datos lógica no hace falta nada distribuido, y si son dos instancias PostgreSQL, PREPARE TRANSACTION entre ellas, coordinado por inventario, es adecuado: participantes homogéneos, sin pasarelas, transacción corta y el propio servicio puede resolver transacciones preparadas huérfanas al arrancar (consultando pg_prepared_xacts). Una saga sería sobreingeniería.
  2. Saga coreografiada: reparto publica pedido.entregado en la misma transacción que su cambio de estado (outbox), y pedidos, analitica y el servicio de notificaciones lo consumen de forma independiente e idempotente. No hay compensación posible ni necesaria (una entrega no se "des-entrega"), no hay pivote, y ningún paso depende del resultado de otro: es la reacción simple y estable para la que la coreografía encaja.
  3. TCC: las tres últimas botellas son un recurso escaso y disputado, y el cobro puede tardar minutos por la autenticación reforzada. inventario hace try (reserva provisional con caducidad, por ejemplo 15 minutos), pagos hace try (pre-autorización pendiente de confirmación del cliente); cuando el banco confirma, el orquestador ejecuta confirm en ambos (consumir la reserva, capturar el cobro); si Lucía no confirma a tiempo, cancel en ambos. Durante la espera, el catálogo muestra las botellas como reservadas, no vendidas ni disponibles: el estado provisional explícito del apartado 10. Una saga simple con cobro inmediato no puede esperar minutos con el stock descontado sin aparecer como vendido, y 2PC no puede incluir a la pasarela.

Conclusión

La transacción del monolito, con su COMMIT que lo confirmaba todo o nada, no existe cuando el pedido cruza pedidos, inventario y pagos, y esta lección ha mostrado las dos maneras de vivir sin ella. El commit en dos fases conserva la atomicidad con un coordinador que recoge votos en la fase de prepare y difunde la decisión en la de commit; PostgreSQL lo soporta con PREPARE TRANSACTION, y lo hemos visto bloquear filas indefinidamente cuando el coordinador muere con los participantes preparados. Ese bloqueo, junto con el acoplamiento, la latencia y la imposibilidad de incluir pasarelas y brokers, es la razón por la que los microservicios evitan 2PC (3PC no ayuda sin red síncrona; el remedio real es un coordinador replicado por consenso, que solo las bases de datos distribuidas se permiten). La saga sustituye la atomicidad por una secuencia de transacciones locales con compensaciones, ordenadas en compensables, pivote y reintentables. La hemos implementado como coreografía, con consumidores idempotentes y outbox encadenando pedido.creado, stock.reservado, pago.rechazado y stock.liberado, y como orquestación en saga_pedido.py, con la tabla sagas de km0_pedidos persistiendo cada transición y un continuar reentrante que retoma la saga tras un reinicio gracias a la idempotencia de id_reserva y de la clave de cobro heredadas del Módulo 2; la simulación ha compensado la reserva de Marc cuando pagos lo rechazó y ha dejado el stock intacto. La tabla del apartado 9 justifica la elección de la orquestación para el pedido de Kilómetro Cero, y el apartado 10 ha puesto nombre a lo que la saga pierde, el aislamiento, con contramedidas (bloqueo semántico, estados provisionales, lecturas pesimistas, valores conmutativos) y con TCC como variante para recursos disputados.

Con esta lección se cierra el Módulo 3. Sabemos ya decir con precisión qué garantiza un almacén replicado (modelos de consistencia), qué hay que sacrificar cuando la red se parte y qué cuesta la consistencia cuando no (CAP y PACELC), cómo un grupo de nodos elige líder y acuerda valores (Paxos, Raft, etcd), cómo se copian los datos entre nodos y con qué anomalías (líder-seguidor, multilíder, quórums), y cómo una operación de negocio atraviesa varios servicios sin una transacción global (2PC y sagas). Todo ello trata los datos como si ya estuvieran en algún sitio. El Módulo 4 se ocupa de ese sitio: dónde y cómo se guardan físicamente los pedidos, las fotos de los productos y los eventos que ya sabemos replicar y coordinar, cuando son demasiados para un solo nodo. Empieza por la pregunta previa a cualquier réplica: cómo repartir datos distintos entre muchos nodos, con el particionado y el hashing consistente.

Curso de Arquitecturas Distribuidas

Módulo 1: Introducción a los Sistemas Distribuidos

Módulo 2: Comunicación en Sistemas Distribuidos

Módulo 3: Consistencia y Replicación

Módulo 4: Almacenamiento Distribuido

Módulo 5: Computación Distribuida

Módulo 6: Seguridad en Sistemas Distribuidos

Módulo 7: Monitoreo y Mantenimiento

Módulo 8: Casos de Estudio y Aplicaciones

© Copyright 2026. Todos los derechos reservados