La lliçó anterior va acabar amb la pregunta més incòmoda del mòdul. Al monòlit de 01-06, crear una comanda era una transacció: BEGIN, inserir la comanda, descomptar l'estoc, registrar el cobrament, COMMIT; si qualsevol pas fallava, ROLLBACK i com si no hagués passat res. Avui aquesta mateixa operació creua tres serveis amb tres bases de dades, km0_comandes, km0_inventari i km0_pagaments, cadascuna replicada com vam veure a 03-04, i cap transacció de PostgreSQL no abasta les tres. Si el cobrament d'en Marc és rebutjat després d'haver descomptat l'últim formatge-curat, algú l'ha de retornar a l'estoc; si comandes mor entre el descompte i el cobrament, la comanda d'en Marc queda en un llimb que ningú no ha dissenyat.

Aquesta lliçó presenta les dues famílies de resposta. La primera intenta conservar l'atomicitat del monòlit amb un protocol de commit en dues fases (2PC), que PostgreSQL suporta amb PREPARE TRANSACTION; veurem com funciona, per què bloqueja quan el coordinador cau i per què els microserveis l'eviten. La segona renuncia a l'atomicitat i la substitueix per una saga: una seqüència de transaccions locals, cadascuna amb la seva compensació, coordinada per esdeveniments (coreografia) o per un orquestrador amb estat persistit. Implementarem l'orquestrador de comandes en Python, amb la seva taula sagas a km0_comandes, i el veurem compensar l'estoc quan pagaments rebutja en Marc; després escriurem la mateixa saga com a coreografia amb consumidors Kafka i compararem totes dues. Acabarem amb el que les sagues perden respecte d'ACID (l'aïllament) i les contramesures, i amb el patró TCC. La conclusió tanca el Mòdul 3 i obre el Mòdul 4, dedicat a on i com s'emmagatzemen físicament les dades que ja sabem replicar i coordinar.

Contingut

  1. ACID i la transacció que ja no existeix
  2. Commit en dues fases (2PC)
  3. Per què 2PC bloqueja, i 3PC
  4. 2PC real: XA i PREPARE TRANSACTION a PostgreSQL
  5. Per què els microserveis eviten 2PC
  6. Sagues: transaccions locals amb compensacions
  7. Coreografia: la saga com a cadena d'esdeveniments
  8. Orquestració: saga_comanda.py amb estat persistit
  9. Taula coreografia vs orquestració
  10. Compensacions, idempotència i l'aïllament perdut; TCC
  11. Errors comuns i consells
  12. Exercicis
  13. Conclusió

  1. ACID i la transacció que ja no existeix

Recordem què prometia la transacció del monòlit, perquè cada lletra d'ACID tindrà un destí diferent al sistema distribuït:

Propietat Què garanteix Què li passa en distribuir
Atomicitat Tots els passos o cap És el que 2PC intenta conservar i el que les sagues substitueixen per compensacions
Consistència (d'ACID) Es respecten els invariants de l'esquema (claus, restriccions) Continua sent local a cada base de dades; els invariants entre serveis ("no hi ha comanda pagada sense estoc reservat") passen a ser responsabilitat de l'aplicació
Aïllament Les transaccions concurrents no es veuen a mitges Es perd entre serveis: els altres veuen els passos intermedis de la saga (apartat 10)
Durabilitat Allò confirmat sobreviu a les fallades La dona cada base de dades per separat (amb la replicació de 03-04)

La transacció original, a km0/sql/monolit.sql de 01-06, era essencialment aquesta:

BEGIN;
INSERT INTO comandes (id, client, estat, total) VALUES ('P-2026-000124', 'Marc', 'confirmada', 24.90);
UPDATE estoc SET unitats = unitats - 1 WHERE producte = 'formatge-curat' AND unitats >= 1;
INSERT INTO cobraments (comanda_id, import_eur, estat) VALUES ('P-2026-000124', 24.90, 'cobrat');
COMMIT;

Si l'UPDATE no afectava cap fila (sense estoc) o la passarel·la de pagament fallava, un ROLLBACK ho desfeia tot. Avui les tres sentències viuen en tres serveis, i la pregunta és què substitueix el COMMIT.

  1. Commit en dues fases (2PC)

El commit en dues fases (Gray, 1978) és el protocol clàssic de commit atòmic: fer que diversos participants, cadascun amb la seva pròpia transacció local, confirmin tots o avortin tots. Els seus rols:

  • Coordinador: el procés que dirigeix el protocol (en el nostre cas ho seria comandes, o un gestor de transaccions extern).
  • Participants: les bases de dades o serveis amb una transacció local oberta (km0_comandes, km0_inventari, km0_pagaments).
sequenceDiagram
    participant C as Coordinador (comandes)
    participant P as km0_comandes
    participant I as km0_inventari
    participant G as km0_pagaments
    Note over C,G: Fase 0: treball (transaccions locals obertes)
    C->>P: INSERT comanda
    C->>I: UPDATE estoc
    C->>G: INSERT cobrament
    Note over C,G: Fase 1: PREPARE (votació)
    C->>P: prepare
    C->>I: prepare
    C->>G: prepare
    P-->>C: sí (escrit a disc, bloqueigs retinguts)
    I-->>C: sí
    G-->>C: sí
    Note over C: Decisió: COMMIT, escrita al log del coordinador
    Note over C,G: Fase 2: COMMIT
    C->>P: commit
    C->>I: commit
    C->>G: commit
    P-->>C: fet
    I-->>C: fet
    G-->>C: fet

Fase 1, prepare. El coordinador pregunta a cada participant "pots confirmar?". Un participant que respon es compromet de manera irrevocable: escriu la transacció a disc de manera que la pugui confirmar encara que es reiniciï, i manté els seus bloqueigs fins que arribi la decisió. Un participant pot respondre no (violació de restricció, sense estoc, targeta rebutjada), i aleshores la decisió serà avortar.

Fase 2, commit o abort. Si tots han dit que sí, el coordinador escriu la decisió al seu propi log (aquest és el punt de no retorn: a partir d'aquí la transacció està confirmada encara que ningú no ho sàpiga encara) i envia commit a tots; si algun ha dit que no, envia abort. Els participants executen la decisió i responen; el coordinador pot reintentar la fase 2 tantes vegades com calgui, perquè un participant en estat "preparat" sempre pot obeir.

La correcció del protocol es recolza en dues promeses: el participant preparat mai no decideix pel seu compte, i el coordinador mai no oblida una decisió presa. Totes dues exigeixen escriptures a disc (fsync) en el moment adequat, i per això 2PC costa com a mínim dos viatges de xarxa i dos fsync per participant, a més del treball en si.

  1. Per què 2PC bloqueja, i 3PC

El punt feble és a la paraula "mai" de la primera promesa. Imagina que els tres participants han respost que sí i el coordinador mor just després, abans d'enviar la decisió (o fins i tot abans d'escriure-la). Els participants són en estat preparat: amb els bloqueigs retinguts (la fila d'estoc de formatge-curat bloquejada, la comanda d'en Marc invisible per a les lectures que necessiten el bloqueig) i sense poder decidir res:

  • No poden avortar, perquè potser el coordinador havia decidit commit i ja ho havia dit a un altre participant.
  • No poden confirmar, perquè potser un altre participant va dir que no.
  • Preguntar als altres participants no sempre ajuda: si tots estan preparats, cap no sap la decisió.

Només poden esperar que el coordinador torni i llegeixi el seu log. Si el coordinador triga una hora, aquesta fila d'estoc està bloquejada una hora; és el que s'anomena el bloqueig (blocking) de 2PC, i és un problema de disponibilitat en el sentit de 03-02: el sistema sacrifica A (participants vius que no poden avançar) per preservar l'atomicitat. A la pràctica, els operadors acaben resolent transaccions preparades a mà (decidint commit o abort per inspecció), cosa que s'anomena heurística, amb el risc evident de decidir diferent del que va decidir el coordinador.

El commit en tres fases (3PC, Skeen, 1981) afegeix una fase intermèdia (pre-commit) perquè els participants puguin deduir la decisió sense el coordinador, i elimina el bloqueig... a costa d'assumir una xarxa síncrona amb retards acotats i sense particions, precisament el que 01-02 i 03-02 ens van dir que no tenim. Amb una partició, 3PC pot portar un costat a confirmar i l'altre a avortar. Per això gairebé ningú no el fa servir, i per això la solució moderna al bloqueig de 2PC és fer el coordinador tolerant a fallades amb consens (03-03): Spanner, per exemple, executa 2PC entre grups Paxos, de manera que el "coordinador" és un grup replicat que no mor. És correcte i és caríssim.

  1. 2PC real: XA i PREPARE TRANSACTION a PostgreSQL

L'estàndard XA (X/Open, anys 90) defineix la interfície entre un gestor de transaccions i els recursos participants (bases de dades, cues); Java l'exposa com a JTA, i els servidors d'aplicacions clàssics el feien servir per a transaccions que abastaven una base de dades i una cua JMS. PostgreSQL implementa el costat del participant amb tres sentències: PREPARE TRANSACTION 'id' (fase 1: la transacció actual queda preparada i sobreviu a reinicis), COMMIT PREPARED 'id' i ROLLBACK PREPARED 'id' (fase 2). Requereix max_prepared_transactions > 0 a postgresql.conf (per defecte és 0, deliberadament). Un coordinador mínim en Python entre km0_inventari i km0_comandes:

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

DSN_COMANDES = "host=localhost port=5432 dbname=km0_comandes user=km0 password=km0"
DSN_INVENTARI = "host=localhost port=5434 dbname=km0_inventari user=km0 password=km0"
ID_TX = "comanda-P-2026-000124"          # identificador global de la transacció


def dos_fases(comanda_id: str, client: str, producte: str, total: float) -> None:
    com = psycopg.connect(DSN_COMANDES, autocommit=True)
    inv = psycopg.connect(DSN_INVENTARI, autocommit=True)
    participants = [com, inv]
    try:
        # Fase 0: treball en transaccions locals obertes
        com.execute("BEGIN")
        com.execute("INSERT INTO comandes (id, client, estat, total) VALUES (%s, %s, 'confirmada', %s)",
                    (comanda_id, client, total))
        inv.execute("BEGIN")
        afectades = inv.execute("UPDATE estoc SET unitats = unitats - 1 WHERE producte = %s AND unitats >= 1",
                                (producte,)).rowcount
        if afectades == 0:
            raise RuntimeError(f"sense estoc de {producte}")

        # Fase 1: prepare (cada participant vota; si falla, llança excepció = vot "no")
        for conn in participants:
            conn.execute(f"PREPARE TRANSACTION '{ID_TX}'")
        print("fase 1: tots preparats; la decisió és COMMIT")
        # >>> Si el coordinador mor AQUÍ, totes dues bases de dades queden preparades i bloquejades <<<

        # Fase 2: commit
        for conn in participants:
            conn.execute(f"COMMIT PREPARED '{ID_TX}'")
        print("fase 2: confirmat a totes dues")
    except Exception as e:
        print("avortant:", e)
        for conn in participants:
            try:
                conn.execute(f"ROLLBACK PREPARED '{ID_TX}'")      # si va arribar a preparar-se
            except psycopg.Error:
                conn.execute("ROLLBACK")                           # si no hi va arribar
    finally:
        com.close(); inv.close()


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

Per veure el bloqueig amb els teus propis ulls, mata el procés (o posa-hi un sys.exit()) just després de la fase 1 i consulta en qualsevol de les bases de dades:

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

La transacció preparada sobreviu fins i tot a un reinici del servidor, i mentre existeixi, la fila de formatge-curat està bloquejada: qualsevol altre UPDATE sobre ella espera indefinidament. Només un COMMIT PREPARED o ROLLBACK PREPARED explícit l'allibera, i decidir quin és la resolució heurística de l'apartat 3. (El mateix que PREPARE TRANSACTION existeix a MySQL amb XA PREPARE, i en cues com ActiveMQ o IBM MQ; Kafka no participa en XA, i RabbitMQ tampoc, cosa que ja limita molt el seu ús amb la nostra arquitectura.)

  1. Per què els microserveis eviten 2PC

Amb 2PC funcionant en dues línies de SQL, la pregunta natural és per què no fer-lo servir per a la comanda d'en Marc. Les raons són totes pràctiques:

  1. Acoblament: el coordinador necessita accés transaccional directe a les bases de dades d'inventari i pagaments, cosa que trenca la regla "cada servei és amo de les seves dades" de 01-06. L'alternativa, que cada servei exposi operacions prepare/commit/abort per gRPC, és possible però converteix cada servei en un gestor de recursos XA, amb els seus logs, la seva recuperació i les seves transaccions òrfenes.
  2. Disponibilitat: el bloqueig de l'apartat 3 vol dir que la caiguda de comandes (el coordinador) deixa bloquejades files a inventari i pagaments. Una fallada en un servei es converteix en una fallada en tres: el contrari de l'aïllament de fallades que buscàvem en separar els serveis (01-03).
  3. Latència i rendiment: dues fases amb fsync a cada participant, amb els bloqueigs retinguts durant tot l'intercanvi, limiten el nombre de comandes per segon al que toleri el participant més lent.
  4. Heterogeneïtat: pagaments parla amb una passarel·la externa (02-05) que no participa en cap 2PC: no es pot "preparar" un cobrament amb targeta. Kafka i Redis tampoc no són participants XA. Tan bon punt un pas no és una base de dades relacional, 2PC ja no cobreix l'operació completa.
  5. Operació: les transaccions preparades òrfenes requereixen intervenció manual, i els equips que han operat XA en producció acostumen a descriure-ho com la font més freqüent d'incidents nocturns.

2PC continua sent l'eina correcta dins d'un sistema que ho controla tot (una base de dades distribuïda com Spanner o CockroachDB el fa servir entre els seus propis nodes, amb coordinador replicat per consens), però entre serveis independents la indústria ha convergit en l'alternativa que renuncia a l'atomicitat: la saga.

  1. Sagues: transaccions locals amb compensacions

El patró saga (García-Molina i Salem, 1987, per a transaccions de llarga durada en una sola base de dades) es va reformular per a microserveis fa una dècada. Una saga és una seqüència de transaccions locals T1, T2, ..., Tn, cadascuna en un servei i confirmada per separat, tal que:

  • Si totes tenen èxit, l'operació de negoci està completa.
  • Si Ti falla, s'executen les transaccions compensatòries C(i-1), ..., C1 de les que ja s'havien confirmat, en ordre invers, deixant el sistema en un estat semànticament equivalent a l'inicial (no idèntic: la comanda cancel·lada continua existint com a registre, el cobrament reemborsat apareix a l'extracte d'en Marc).

Per a la comanda de Quilòmetre Zero:

Pas Transacció local Servei Compensació
T1 Crear comanda en estat pendent comandes C1: marcar comanda cancellada
T2 Reservar estoc (ReservarEstoc de 02-03, amb id_reserva) inventari C2: alliberar la reserva
T3 Cobrar (amb Idempotency-Key de 02-05) pagaments C3: reemborsar
T4 Marcar comanda confirmada comandes (cap: és l'últim pas)

Els passos es classifiquen en tres tipus, i l'ordre en què es col·loquen importa:

  • Compensables: tenen compensació (T1, T2). Van al principi.
  • Pivot: el pas que decideix si la saga tindrà èxit; un cop confirmat, la saga no pot avortar (T3, el cobrament: si es cobra, la comanda surt). Acostuma a ser el pas amb més probabilitat de fallar o el que no es pot compensar netament, i es col·loca tan tard com sigui possible.
  • Reintentables: després del pivot, passos que no poden fallar de manera permanent, només transitòria, i es reintenten fins que funcionin (T4).
sequenceDiagram
    participant Com as comandes
    participant Inv as inventari
    participant Pag as pagaments
    Note over Com,Pag: Saga feliç: la comanda de l'Anna
    Com->>Com: T1 crear P-2026-000123 (pendent)
    Com->>Inv: T2 ReservarEstoc(formatge-curat, id_reserva)
    Inv-->>Com: reservat
    Com->>Pag: T3 cobrar(24,90 €, Idempotency-Key)
    Pag-->>Com: cobrat
    Com->>Com: T4 marcar confirmada
sequenceDiagram
    participant Com as comandes
    participant Inv as inventari
    participant Pag as pagaments
    Note over Com,Pag: Saga amb compensació: la comanda d'en Marc
    Com->>Com: T1 crear P-2026-000124 (pendent)
    Com->>Inv: T2 ReservarEstoc(formatge-curat, id_reserva)
    Inv-->>Com: reservat
    Com->>Pag: T3 cobrar(24,90 €)
    Pag-->>Com: REBUTJAT (targeta)
    Com->>Inv: C2 AlliberarReserva(id_reserva)
    Inv-->>Com: alliberada
    Com->>Com: C1 marcar cancellada (motiu: pagament rebutjat)

Hi ha dues maneres de coordinar qui executa cada pas i cada compensació, i són l'objecte dels dos apartats següents.

  1. Coreografia: la saga com a cadena d'esdeveniments

En una saga coreografiada no hi ha coordinador: cada servei reacciona als esdeveniments dels altres i publica els seus, pel tòpic comandes.esdeveniments de 02-04 i 02-05 (o tòpics per servei). La cadena d'esdeveniments de la comanda:

flowchart LR
    A[comandes:<br/>comanda.creada] --> B[inventari:<br/>estoc.reservat]
    A --> B2[inventari:<br/>estoc.insuficient]
    B --> C[pagaments:<br/>pagament.confirmat]
    B --> C2[pagaments:<br/>pagament.rebutjat]
    C --> D[comandes:<br/>comanda.confirmada]
    C2 --> E[inventari:<br/>estoc.alliberat]
    B2 --> F[comandes:<br/>comanda.cancellada]
    E --> F

Cada servei implementa la seva part com un consumidor idempotent de 02-05 que, a la mateixa transacció, aplica l'efecte local i escriu l'esdeveniment següent a la seva outbox. El fragment d'inventari, que reacciona a comanda.creada i a pagament.rebutjat:

# km0/serveis/inventari/saga_consumidor.py
import json
import uuid
import psycopg
from confluent_kafka import Consumer

DSN = "host=inventari-db dbname=km0_inventari user=km0 password=km0"
consumidor = Consumer({"bootstrap.servers": "kafka:9092", "group.id": "inventari-saga",
                       "enable.auto.commit": False, "auto.offset.reset": "earliest"})
consumidor.subscribe(["comandes.esdeveniments", "pagaments.esdeveniments"])


def publicar(cur, tipus: str, comanda_id: str, dades: dict) -> None:
    """Escriu a l'outbox (02-05); el relay ho publicarà a inventari.esdeveniments."""
    cur.execute("INSERT INTO outbox (id, agregat, agregat_id, tipus, carrega) VALUES (%s, 'comanda', %s, %s, %s)",
                (uuid.uuid4(), comanda_id, tipus, json.dumps({"comanda_id": comanda_id, **dades})))


def processar(conn: psycopg.Connection, esdeveniment: dict) -> None:
    comanda_id = esdeveniment["dades"]["comanda_id"]
    with conn.transaction():
        cur = conn.cursor()
        cur.execute("INSERT INTO missatges_processats (id_missatge, consumidor) VALUES (%s, 'inventari.saga') "
                    "ON CONFLICT DO NOTHING", (esdeveniment["id_esdeveniment"],))
        if cur.rowcount == 0:
            return                                                  # duplicat: ja processat
        if esdeveniment["tipus"] == "comanda.creada":               # T2: reservar
            ok = True
            for linia in esdeveniment["dades"]["linies"]:
                cur.execute("UPDATE estoc SET unitats = unitats - %s WHERE producte = %s AND unitats >= %s",
                            (linia["quantitat"], linia["producte"], linia["quantitat"]))
                ok = ok and cur.rowcount == 1
            if not ok:
                raise psycopg.Rollback                              # desfà els UPDATE parcials...
            cur.execute("INSERT INTO reserves (comanda_id, linies) VALUES (%s, %s)",
                        (comanda_id, json.dumps(esdeveniment["dades"]["linies"])))
            publicar(cur, "estoc.reservat", comanda_id, {"linies": esdeveniment["dades"]["linies"]})
        elif esdeveniment["tipus"] == "pagament.rebutjat":          # C2: alliberar
            cur.execute("DELETE FROM reserves WHERE comanda_id = %s RETURNING linies", (comanda_id,))
            fila = cur.fetchone()
            if fila:                                                # si no hi ha reserva, ja es va alliberar
                for linia in json.loads(fila[0]) if isinstance(fila[0], str) else fila[0]:
                    cur.execute("UPDATE estoc SET unitats = unitats + %s WHERE producte = %s",
                                (linia["quantitat"], linia["producte"]))
            publicar(cur, "estoc.alliberat", comanda_id, {"motiu": "pagament rebutjat"})


def processar_sense_estoc(conn: psycopg.Connection, esdeveniment: dict) -> None:
    """Branca de fallada de T2: es publica en una transacció a part, després del rollback."""
    with conn.transaction():
        cur = conn.cursor()
        cur.execute("INSERT INTO missatges_processats (id_missatge, consumidor) VALUES (%s, 'inventari.saga') "
                    "ON CONFLICT DO NOTHING", (esdeveniment["id_esdeveniment"],))
        publicar(cur, "estoc.insuficient", esdeveniment["dades"]["comanda_id"], {})


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

(psycopg.Rollback és l'excepció que conn.transaction() captura per desfer el bloc sense propagar-la; aquí la reutilitzem com a senyal de "sense estoc". Amb el mateix estil, pagaments consumeix estoc.reservat, cobra amb la Idempotency-Key de 02-05 i publica pagament.confirmat o pagament.rebutjat; i comandes consumeix pagament.confirmat per a T4 i estoc.insuficient/estoc.alliberat per a C1.)

La coreografia és atractiva per la seva simplicitat aparent: no hi ha cap component nou, només consumidors. Els seus problemes apareixen en créixer: per saber en quin estat és la comanda d'en Marc cal reconstruir-lo a partir d'esdeveniments repartits per tres tòpics; afegir un pas (per exemple, "assignar repartidor" entre el cobrament i la confirmació) obliga a tocar diversos serveis; i les dependències cícliques (comandes escolta pagaments, que escolta inventari, que escolta comandes) fan difícil raonar sobre el flux complet. Amb tres passos és manejable; amb vuit, no.

  1. Orquestració: saga_comanda.py amb estat persistit

En una saga orquestrada, un component, l'orquestrador, sap quina és la seqüència, invoca cada pas (per gRPC síncron o publicant ordres), interpreta la resposta i decideix el pas següent o la compensació. L'orquestrador és una màquina d'estats l'estat de la qual es persisteix després de cada transició, de manera que si el procés mor, una altra instància (o la mateixa en reiniciar) la reprèn on era. A Quilòmetre Zero viu a comandes, perquè és el servei amo del concepte "comanda" i qui inicia l'operació, amb aquesta taula a km0_comandes:

-- km0/sql/comandes/sagas.sql
CREATE TABLE sagas (
    id              UUID PRIMARY KEY,
    tipus           TEXT NOT NULL,                 -- 'comanda'
    comanda_id      TEXT NOT NULL UNIQUE,
    estat           TEXT NOT NULL,                 -- INICIADA, ESTOC_RESERVAT, PAGAMENT_CONFIRMAT, COMPLETADA,
                                                   -- COMPENSANT, CANCELLADA
    pas_actual      INTEGER NOT NULL DEFAULT 0,    -- últim pas confirmat amb èxit
    dades           JSONB NOT NULL,                -- el que necessiten els passos i les compensacions
    historial       JSONB NOT NULL DEFAULT '[]',
    creada_en       TIMESTAMPTZ NOT NULL DEFAULT now(),
    actualitzada_en TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX sagas_en_curs ON sagas (actualitzada_en) WHERE estat NOT IN ('COMPLETADA', 'CANCELLADA');

L'orquestrador defineix els passos com a parells (acció, compensació), avança persistint, i compensa cap enrere si un pas falla de manera permanent. Perquè l'exemple sigui executable sense infraestructura, els clients d'inventari i pagaments i el repositori de sagues tenen versions simulades; les reals serien l'stub gRPC de 02-03, el client de la passarel·la de 02-05 i psycopg sobre la taula anterior.

# km0/serveis/comandes/saga_comanda.py
import json
import uuid
from dataclasses import dataclass, field
from typing import Callable, Protocol


class FalladaPermanent(Exception):
    """El pas no tindrà èxit encara que es reintenti: cal compensar."""


class FalladaTransitoria(Exception):
    """El pas pot tenir èxit si es reintenta (timeout, 503, deadline gRPC)."""


# --- Estat persistit ---------------------------------------------------------
@dataclass
class Saga:
    id: str
    comanda_id: str
    estat: str = "INICIADA"
    pas_actual: int = 0
    dades: dict = field(default_factory=dict)
    historial: list = field(default_factory=list)


class Repositori(Protocol):
    def desar(self, saga: Saga) -> None: ...
    def carregar(self, saga_id: str) -> Saga: ...


class RepositoriMemoria:
    """Per a la simulació. La versió real fa UPDATE sagas SET ... WHERE id = %s amb psycopg."""
    def __init__(self) -> None:
        self.files: dict[str, str] = {}
    def desar(self, saga: Saga) -> None:
        self.files[saga.id] = json.dumps(saga.__dict__)          # com ho faria JSONB
    def carregar(self, saga_id: str) -> Saga:
        return Saga(**json.loads(self.files[saga_id]))


# --- Passos ------------------------------------------------------------------
@dataclass
class Pas:
    nom: str
    estat_despres_exit: str
    accio: Callable[[Saga], None]
    compensacio: Callable[[Saga], None] | None       # None = pas pivot o posterior


class OrquestradorSagaComanda:
    def __init__(self, repo: Repositori, inventari, pagaments, comandes_db, max_reintents: int = 3):
        self.repo, self.max_reintents = repo, max_reintents
        self.passos = [
            Pas("reservar_estoc", "ESTOC_RESERVAT",
                accio=lambda s: inventari.reservar(s.dades["id_reserva"], s.comanda_id, s.dades["linies"]),
                compensacio=lambda s: inventari.alliberar(s.dades["id_reserva"])),
            Pas("cobrar", "PAGAMENT_CONFIRMAT",
                accio=lambda s: pagaments.cobrar(s.dades["clau_idempotencia"], s.dades["client"], s.dades["total"]),
                compensacio=lambda s: pagaments.reemborsar(s.dades["clau_idempotencia"])),
            Pas("confirmar_comanda", "COMPLETADA",
                accio=lambda s: comandes_db.canviar_estat(s.comanda_id, "confirmada"),
                compensacio=None),
        ]
        self.comandes_db = comandes_db

    def _transicio(self, saga: Saga, estat: str, nota: str) -> None:
        saga.estat = estat
        saga.historial.append(f"{estat}: {nota}")
        self.repo.desar(saga)                                     # persistir ABANS de continuar
        print(f"  [saga {saga.comanda_id}] -> {estat} ({nota})")

    def iniciar(self, comanda_id: str, client: str, linies: list[dict], total: float) -> Saga:
        saga = Saga(id=str(uuid.uuid4()), comanda_id=comanda_id,
                    dades={"client": client, "linies": linies, "total": total,
                           "id_reserva": f"res-{comanda_id}", "clau_idempotencia": f"cobrament-{comanda_id}"})
        self.comandes_db.crear(comanda_id, client, total)         # T1, a la MATEIXA transacció que la saga
        self._transicio(saga, "INICIADA", "comanda creada en estat pendent")
        return self.continuar(saga.id)

    def continuar(self, saga_id: str) -> Saga:
        """Avança (o compensa) des de l'estat persistit. Reentrant: es pot cridar després d'un reinici."""
        saga = self.repo.carregar(saga_id)
        if saga.estat == "COMPENSANT":
            return self._compensar(saga)
        while saga.pas_actual < len(self.passos):
            pas = self.passos[saga.pas_actual]
            for intent in range(1, self.max_reintents + 1):
                try:
                    pas.accio(saga)
                    break
                except FalladaTransitoria as e:
                    print(f"  [saga {saga.comanda_id}] {pas.nom}: fallada transitòria ({e}), intent {intent}")
                    if intent == self.max_reintents and pas.compensacio is None:
                        return saga            # pas reintentable: deixar-lo per a un procés periòdic
                except FalladaPermanent as e:
                    self._transicio(saga, "COMPENSANT", f"{pas.nom} ha fallat: {e}")
                    return self._compensar(saga)
            else:
                self._transicio(saga, "COMPENSANT", f"{pas.nom} ha esgotat els reintents")
                return self._compensar(saga)
            saga.pas_actual += 1
            self._transicio(saga, pas.estat_despres_exit, f"{pas.nom} ok")
        return saga

    def _compensar(self, saga: Saga) -> Saga:
        while saga.pas_actual > 0:
            pas = self.passos[saga.pas_actual - 1]
            if pas.compensacio is not None:
                pas.compensacio(saga)                             # ha de ser idempotent
                print(f"  [saga {saga.comanda_id}]    compensat {pas.nom}")
            saga.pas_actual -= 1
            self.repo.desar(saga)
        self.comandes_db.canviar_estat(saga.comanda_id, "cancellada")   # C1
        self._transicio(saga, "CANCELLADA", "totes les compensacions aplicades")
        return saga


# --- Serveis simulats ----------------------------------------------------------
class InventariSimulat:
    def __init__(self) -> None:
        self.estoc = {"formatge-curat": 1, "vi-crianca": 12}
        self.reserves: dict[str, list[dict]] = {}
    def reservar(self, id_reserva: str, comanda_id: str, linies: list[dict]) -> None:
        if id_reserva in self.reserves:
            return                                                # idempotent (02-03)
        for l in linies:
            if self.estoc[l["producte"]] < l["quantitat"]:
                raise FalladaPermanent(f"sense estoc de {l['producte']}")
        for l in linies:
            self.estoc[l["producte"]] -= l["quantitat"]
        self.reserves[id_reserva] = linies
        print(f"    inventari: reservat {linies} -> estoc {self.estoc}")
    def alliberar(self, id_reserva: str) -> None:
        linies = self.reserves.pop(id_reserva, None)             # idempotent: si no existeix, res
        if linies:
            for l in linies:
                self.estoc[l["producte"]] += l["quantitat"]
            print(f"    inventari: alliberada {id_reserva} -> estoc {self.estoc}")


class PagamentsSimulat:
    def __init__(self) -> None:
        self.cobraments: dict[str, float] = {}
        self.targetes_rebutjades = {"Marc"}
    def cobrar(self, clau: str, client: str, import_eur: float) -> None:
        if clau in self.cobraments:
            return                                                # idempotent (02-05)
        if client in self.targetes_rebutjades:
            raise FalladaPermanent("targeta rebutjada per l'emissor")
        self.cobraments[clau] = import_eur
        print(f"    pagaments: cobrats {import_eur:.2f} EUR a {client}")
    def reemborsar(self, clau: str) -> None:
        if clau in self.cobraments:
            print(f"    pagaments: reemborsats {self.cobraments.pop(clau):.2f} EUR")


class ComandesDbSimulada:
    def __init__(self) -> None:
        self.comandes: dict[str, dict] = {}
    def crear(self, comanda_id: str, client: str, total: float) -> None:
        self.comandes[comanda_id] = {"client": client, "total": total, "estat": "pendent"}
    def canviar_estat(self, comanda_id: str, estat: str) -> None:
        self.comandes[comanda_id]["estat"] = estat


if __name__ == "__main__":
    inventari, pagaments, comandes_db = InventariSimulat(), PagamentsSimulat(), ComandesDbSimulada()
    orq = OrquestradorSagaComanda(RepositoriMemoria(), inventari, pagaments, comandes_db)

    print("Saga 1: l'Anna compra l'últim formatge curat")
    orq.iniciar("P-2026-000123", "Anna", [{"producte": "formatge-curat", "quantitat": 1}], 24.90)

    print("\nSaga 2: en Marc intenta comprar formatge curat (ja no n'hi ha)")
    orq.iniciar("P-2026-000124", "Marc", [{"producte": "formatge-curat", "quantitat": 1}], 24.90)

    print("\nSaga 3: en Marc compra vi criança, però la seva targeta és rebutjada")
    orq.iniciar("P-2026-000125", "Marc", [{"producte": "vi-crianca", "quantitat": 2}], 31.80)

    print("\nEstat final de les comandes:")
    for cid, c in comandes_db.comandes.items():
        print(f"  {cid}: {c['estat']}")
    print("estoc final:", inventari.estoc, "| cobraments:", pagaments.cobraments)

Sortida:

Saga 1: l'Anna compra l'últim formatge curat
  [saga P-2026-000123] -> INICIADA (comanda creada en estat pendent)
    inventari: reservat [{'producte': 'formatge-curat', 'quantitat': 1}] -> estoc {'formatge-curat': 0, 'vi-crianca': 12}
  [saga P-2026-000123] -> ESTOC_RESERVAT (reservar_estoc ok)
    pagaments: cobrats 24.90 EUR a Anna
  [saga P-2026-000123] -> PAGAMENT_CONFIRMAT (cobrar ok)
  [saga P-2026-000123] -> COMPLETADA (confirmar_comanda ok)

Saga 2: en Marc intenta comprar formatge curat (ja no n'hi ha)
  [saga P-2026-000124] -> INICIADA (comanda creada en estat pendent)
  [saga P-2026-000124] -> COMPENSANT (reservar_estoc ha fallat: sense estoc de formatge-curat)
  [saga P-2026-000124] -> CANCELLADA (totes les compensacions aplicades)

Saga 3: en Marc compra vi criança, però la seva targeta és rebutjada
  [saga P-2026-000125] -> INICIADA (comanda creada en estat pendent)
    inventari: reservat [{'producte': 'vi-crianca', 'quantitat': 2}] -> estoc {'formatge-curat': 0, 'vi-crianca': 10}
  [saga P-2026-000125] -> ESTOC_RESERVAT (reservar_estoc ok)
  [saga P-2026-000125] -> COMPENSANT (cobrar ha fallat: targeta rebutjada per l'emissor)
    inventari: alliberada res-P-2026-000125 -> estoc {'formatge-curat': 0, 'vi-crianca': 12}
  [saga P-2026-000125]    compensat reservar_estoc
  [saga P-2026-000125] -> CANCELLADA (totes les compensacions aplicades)

Estat final de les comandes:
  P-2026-000123: confirmada
  P-2026-000124: cancellada
  P-2026-000125: cancellada
estoc final: {'formatge-curat': 0, 'vi-crianca': 12} | cobraments: {'cobrament-P-2026-000123': 24.9}

Els punts importants del codi, per a qui el llegeixi per primera vegada:

  • Persistir abans de continuar. _transicio desa la saga a cada canvi d'estat, i pas_actual s'incrementa només després de l'èxit del pas. Si el procés mor entre pas.accio(saga) i _transicio, en reiniciar continuar(saga_id) tornarà a executar el mateix pas, i per això cada acció ha de ser idempotent: reservar amb el mateix id_reserva no descompta dues vegades (02-03), cobrar amb la mateixa clau_idempotencia no cobra dues vegades (02-05). La saga hereta directament el treball del Mòdul 2.
  • Els identificadors es decideixen a l'inici. id_reserva i clau_idempotencia es deriven del comanda_id i es desen a dades a la primera transició, perquè un reintent després d'un reinici faci servir exactament els mateixos.
  • Fallada permanent davant de transitòria. Una FalladaPermanent (sense estoc, targeta rebutjada) dispara la compensació; una FalladaTransitoria (deadline gRPC, 503 de la passarel·la) es reintenta, i si és en un pas posterior al pivot es deixa per a un procés periòdic que cridi continuar sobre les sagues en curs (l'índex sagas_en_curs existeix per a això).
  • Les compensacions també són idempotents (alliberar una reserva inexistent no fa res) i s'executen en ordre invers, sense tocar els passos que no van arribar a executar-se (a la saga 2, no hi ha res a alliberar).
  • T1 i la saga a la mateixa transacció. A la versió real, comandes_db.crear i el primer desar de la saga van en una única transacció de km0_comandes (i, si la saga es condueix per esdeveniments, la primera ordre va a l'outbox en aquesta mateixa transacció). Així no pot existir una comanda pendent sense saga ni una saga sense comanda.

A les tres sagues simulades, el resultat és el que el monòlit hauria donat amb ROLLBACK: l'Anna té el seu formatge i el seu cobrament; en Marc no té ni formatge ni vi, no se li ha cobrat res, i l'estoc de vi ha tornat a 12. La diferència és que ha passat en passos separats, visibles des de fora, i que les comandes cancel·lades existeixen com a registre.

  1. Taula coreografia vs orquestració

Aspecte Coreografia Orquestració
Qui sap la seqüència Ningú en concret: està repartida entre els consumidors L'orquestrador
Components nous Cap (consumidors + outbox) L'orquestrador i la seva taula d'estat
Acoblament Baix entre serveis, però cadascun coneix els esdeveniments dels altres Els serveis només coneixen les seves ordres; l'orquestrador els coneix tots
Saber en quin estat és la comanda d'en Marc Reconstruir-lo a partir d'esdeveniments en diversos tòpics SELECT estat FROM sagas WHERE comanda_id = ...
Afegir un pas Tocar diversos serveis Tocar l'orquestrador (i el servei nou)
Dependències cícliques Fàcils de crear sense voler Impossibles: l'orquestrador és l'únic que crida
Risc de "déu" No L'orquestrador pot acumular lògica de negoci que no li correspon
Proves Integració amb broker L'orquestrador es prova aïllat amb dobles (com a la simulació)
Recomanació habitual Sagues de 2-3 passos, fluxos estables Sagues de 4+ passos, fluxos que canvien, necessitat de consultar l'estat
Eines Kafka/RabbitMQ + outbox Temporal, Camunda/Zeebe, AWS Step Functions, o un orquestrador propi com el de l'apartat 8

A Quilòmetre Zero, la saga de la comanda té tres passos avui però creixerà (assignació de repartidor, avís al productor, cupons de campanya), i l'equip de suport necessita saber en quin estat és cada comanda; per això l'elecció és l'orquestració a comandes. La coreografia es reserva per a reaccions simples i estables, com que analitica consumeixi comanda.confirmada.

  1. Compensacions, idempotència i l'aïllament perdut; TCC

Compensar no és desfer

Una compensació és una nova transacció de negoci amb efectes propis, no un ROLLBACK: el reemborsament apareix a l'extracte d'en Marc, la reserva alliberada genera un estoc.actualitzat que cataleg mostrarà, la comanda cancel·lada es queda a l'historial amb el seu motiu. Algunes accions no tenen compensació possible (un correu enviat, un repartidor que ja ha sortit) i s'han de col·locar després del pivot. I tota compensació ha de ser idempotent i no pot fallar de manera permanent: si reemborsar retorna un error definitiu, la saga queda en un estat que exigeix intervenció humana, i cal dissenyar per a això (un estat COMPENSACIO_FALLIDA amb alerta, 07-01).

L'aïllament perdut

Les sagues renuncien a la I d'ACID, i això produeix anomalies concretes entre T1 i T4:

Anomalia Exemple a la comanda Contramesura
Actualitzacions perdudes Mentre la saga d'en Marc és a ESTOC_RESERVAT, una altra saga cancel·la la comanda i allibera; després la primera la confirma Bloqueig semàntic: marcar la comanda com a pendent (o en_saga) i rebutjar altres operacions sobre ella fins que la saga acabi; és el que fa T1
Lectures brutes cataleg mostra l'estoc descomptat per T2 abans que T3 decideixi, i després torna a pujar Estats "pendent de confirmar": distingir estoc reservat d'estoc venut; el catàleg mostra disponible = unitats - reservat i sap que és provisional
Lectures no repetibles / fuzzy analitica suma les vendes del dia i compta la comanda d'en Marc abans que es cancel·li Lectura pessimista: només comptar comandes en estat confirmada; o reordenar la saga perquè el pas més volàtil vagi primer (reordenació)
Decisions sobre dades intermèdies Un cupó de la "Setmana del Formatge Artesà" s'aplica a una comanda que després es cancel·la, i el comptador de cupons no es restaura Valor commutatiu: fer servir operacions que es puguin compensar exactament (incrementar/decrementar, com el PNCounter de 03-01) en lloc d'assignacions

Aquestes contramesures (bloqueig semàntic, lectures pessimistes, reordenació, valors commutatius, i "versió d'arxiu" per conservar l'estat previ) les va catalogar Chris Richardson a partir de l'article original de García-Molina, i la majoria es redueixen a una idea: fer visible que l'estat és provisional en lloc de fingir aïllament.

TCC: Try-Confirm/Cancel

Una variant de la saga que recupera una mica d'aïllament és TCC (Try-Confirm/Cancel): cada pas es divideix en try (reservar el recurs de manera provisional, sense consumir-lo), confirm (consumir-lo definitivament) i cancel (alliberar la reserva). L'orquestrador executa tots els try, i només si tots tenen èxit executa els confirm; en cas contrari, els cancel. És 2PC a nivell de negoci, sense bloqueigs de base de dades: inventari amb ReservarEstoc i ConfirmarReserva/AlliberarReserva ja és, de fet, un recurs TCC, i la passarel·la de pagament amb preautorització i captura, també. El preu és que cada servei ha d'implementar les tres operacions i gestionar reserves que caduquen (un try sense confirm ni cancel perquè l'orquestrador ha mort), cosa que fa de TCC l'elecció quan els recursos són escassos i disputats (l'última unitat, una plaça, un seient) i de la saga simple l'elecció per a tota la resta.

Errors Comuns i Consells

  • Fer servir 2PC entre microserveis "perquè és el que fa la base de dades". Acobla, bloqueja davant la caiguda del coordinador i no cobreix passarel·les ni brokers. Reserva 2PC per a l'interior d'un sistema que ho controla tot.
  • Sagues amb passos no idempotents. Després d'un reinici, l'orquestrador reexecuta l'últim pas. Sense id_reserva, sense clau d'idempotència, l'estoc es descompta dues vegades i el client paga dues vegades. Els identificadors d'idempotència es generen en iniciar la saga i es persisteixen amb ella.
  • Compensacions que poden fallar sense pla. Dissenya l'estat de "compensació fallida", amb alerta i procediment manual, abans que passi en producció.
  • El pivot al lloc equivocat. Si el pas que més falla (el cobrament) va primer, es compensa poc; si va últim, es compensa tot l'anterior. Ordena: compensables, després el pivot, després els reintentables.
  • Fingir aïllament. Mostrar l'estoc descomptat per una saga en curs com a definitiu, o comptar comandes pendents com a vendes, produeix els errors de la taula de l'apartat 10. Modela explícitament els estats provisionals.
  • Orquestrador amb lògica de negoci d'altres serveis. L'orquestrador decideix l'ordre i les compensacions; no decideix si hi ha estoc ni si la targeta és vàlida. Si comença a fer-ho, s'ha convertit en un monòlit distribuït.
  • Sagues sense timeout. Una saga a ESTOC_RESERVAT durant hores perquè pagaments no respon reté estoc que altres voldrien comprar. Defineix un termini màxim per pas i per saga, després del qual es compensa.
  • Consell: desa l'historial de la saga. Quan un client pregunti per què es va cancel·lar la seva comanda, "cobrar ha fallat: targeta rebutjada per l'emissor" a la fila de sagas val més que qualsevol log.
  • Consell: prova l'orquestrador amb dobles que fallin a cada pas, de manera permanent i transitòria, i després d'un "reinici" (cridar continuar amb l'estat desat). És el tipus de prova que a 07-06 automatitzarem amb enginyeria del caos.

Exercicis

Exercici 1: Reinici a mitja saga

Amb la simulació de l'apartat 8, modela una caiguda de l'orquestrador just després que inventari reservi el vi de la Llúcia (P-2026-000126, 2 unitats de vi-crianca, targeta vàlida) però abans de la transició a ESTOC_RESERVAT. Fes-ho amb un InventariSimulat el reservar del qual llanci una excepció SystemExit la primera vegada, després d'haver reservat. Després crea un nou OrquestradorSagaComanda amb el mateix RepositoriMemoria, els mateixos serveis simulats, i crida continuar(saga.id). Quins passos es reexecuten? Es descompta l'estoc dues vegades? Què canviaries a la implementació perquè iniciar retornés el saga.id encara que el procés mori a mitges?

Exercici 2: Afegir un pas a les dues versions

Quilòmetre Zero vol afegir "assignar repartidor" (repartiment.assignar(comanda_id, ciutat), compensació repartiment.desassignar(comanda_id), pot fallar de manera permanent si no hi ha repartidors a la ciutat) entre el cobrament i la confirmació. Descriu què cal canviar a l'orquestració (apartat 8) i a la coreografia (apartat 7): quins fitxers, quins esdeveniments nous, quins consumidors. És correcte col·locar-lo després del cobrament? Què implicaria per a en Marc, la targeta del qual sí que és vàlida aquesta vegada, una fallada permanent en aquest pas?

Exercici 3: Triar 2PC, saga o TCC

Per a cada operació, tria 2PC, saga coreografiada, saga orquestrada o TCC, i justifica-ho en dues o tres frases:

  1. Traslladar 50 unitats de tomaquet-rosa entre les taules estoc d'inv-bcn i inv-vlc (totes dues són PostgreSQL del mateix servei inventari, sense passarel·les externes).
  2. Registrar un lliurament: repartiment marca la comanda lliurada, comandes canvia l'estat, analitica actualitza el temps mitjà de lliurament, i s'envia un correu a l'Anna.
  3. Reservar les últimes 3 ampolles de vi-crianca per a una comanda de la Llúcia i cobrar-les amb una targeta que requereix autenticació reforçada (el client pot trigar minuts a confirmar-la a l'app del banc).

Solucions

Solució 1:

class InventariQueMor(InventariSimulat):
    def __init__(self) -> None:
        super().__init__(); self.ja_ha_mort = False
    def reservar(self, id_reserva, comanda_id, linies):
        super().reservar(id_reserva, comanda_id, linies)         # la reserva SÍ que es fa
        if not self.ja_ha_mort:
            self.ja_ha_mort = True
            raise SystemExit("el procés de comandes mor aquí")

repo, inventari, pagaments, comandes_db = RepositoriMemoria(), InventariQueMor(), PagamentsSimulat(), ComandesDbSimulada()
try:
    OrquestradorSagaComanda(repo, inventari, pagaments, comandes_db).iniciar(
        "P-2026-000126", "Llúcia", [{"producte": "vi-crianca", "quantitat": 2}], 31.80)
except SystemExit as e:
    print("!!!", e)
saga_id = next(iter(repo.files))                                  # a la realitat: SELECT ... WHERE estat NOT IN (...)
orq2 = OrquestradorSagaComanda(repo, inventari, pagaments, comandes_db)  # "nova instància" després del reinici
orq2.continuar(saga_id)
print(inventari.estoc, comandes_db.comandes["P-2026-000126"]["estat"])

La saga va quedar persistida a INICIADA amb pas_actual = 0, així que continuar reexecuta reservar_estoc. Com que InventariSimulat.reservar és idempotent per id_reserva (res-P-2026-000126 ja és a reserves), no descompta dues vegades: l'estoc queda a 10, es cobra a la Llúcia i la saga arriba a COMPLETADA. Si treus la comprovació if id_reserva in self.reserves, veuràs l'estoc a 8: és exactament la fallada que la idempotència de 02-03 evita. Sobre iniciar: a la implementació real no retorna res a ningú si el procés mor; l'important és que la saga sigui a la taula, i un procés periòdic (SELECT id FROM sagas WHERE estat NOT IN ('COMPLETADA','CANCELLADA') AND actualitzada_en < now() - interval '1 minute') cridi continuar sobre les sagues òrfenes. A més, iniciar hauria de crear la comanda i la saga en una sola transacció i respondre al client "comanda rebuda, pendent" abans d'executar els passos, que poden trigar.

Solució 2:

Orquestració: un sol canvi a saga_comanda.py: inserir un Pas("assignar_repartidor", "REPARTIDOR_ASSIGNAT", accio=repartiment.assignar(...), compensacio=repartiment.desassignar(...)) a la llista self.passos entre cobrar i confirmar_comanda, afegir ciutat a dades, i admetre el nou estat a la taula sagas. Els serveis inventari i pagaments no canvien. Coreografia: repartiment necessita un consumidor nou de pagament.confirmat que publiqui repartidor.assignat o repartiment.impossible; comandes deixa de confirmar en rebre pagament.confirmat i passa a fer-ho amb repartidor.assignat; pagament.rebutjat continua igual, però repartiment.impossible ha de disparar dues compensacions: pagaments l'ha de consumir i reemborsar (consumidor nou) i després inventari ha d'alliberar en rebre pagament.reemborsat (esdeveniment nou i consumidor nou). Tres serveis tocats, dos esdeveniments nous, i la cadena de compensació s'allarga.

Després del cobrament? Si "assignar repartidor" pot fallar de manera permanent, col·locar-lo després del pivot (cobrament) obliga a reemborsar, que és la compensació més visible i molesta per al client (en Marc veuria un càrrec i un abonament al seu extracte). Seria millor col·locar-lo abans del cobrament (reservar repartidor és compensable i barat de desfer), convertint el cobrament de nou en l'últim pas compensable-o-pivot. Regla general: els passos que poden fallar permanentment van abans del pivot.

Solució 3:

  1. 2PC (o, millor, una única transacció): totes dues taules pertanyen al mateix servei, inventari, així que no hi ha acoblament indegut; si són a la mateixa base de dades lògica no cal res distribuït, i si són dues instàncies PostgreSQL, PREPARE TRANSACTION entre elles, coordinat per inventari, és adequat: participants homogenis, sense passarel·les, transacció curta i el mateix servei pot resoldre transaccions preparades òrfenes en arrencar (consultant pg_prepared_xacts). Una saga seria sobreenginyeria.
  2. Saga coreografiada: repartiment publica comanda.lliurada a la mateixa transacció que el seu canvi d'estat (outbox), i comandes, analitica i el servei de notificacions el consumeixen de manera independent i idempotent. No hi ha compensació possible ni necessària (un lliurament no es "des-lliura"), no hi ha pivot, i cap pas no depèn del resultat d'un altre: és la reacció simple i estable per a la qual la coreografia encaixa.
  3. TCC: les tres últimes ampolles són un recurs escàs i disputat, i el cobrament pot trigar minuts per l'autenticació reforçada. inventari fa try (reserva provisional amb caducitat, per exemple 15 minuts), pagaments fa try (preautorització pendent de confirmació del client); quan el banc confirma, l'orquestrador executa confirm a tots dos (consumir la reserva, capturar el cobrament); si la Llúcia no confirma a temps, cancel a tots dos. Durant l'espera, el catàleg mostra les ampolles com a reservades, ni venudes ni disponibles: l'estat provisional explícit de l'apartat 10. Una saga simple amb cobrament immediat no pot esperar minuts amb l'estoc descomptat sense que aparegui com a venut, i 2PC no pot incloure la passarel·la.

Conclusió

La transacció del monòlit, amb el seu COMMIT que ho confirmava tot o res, no existeix quan la comanda creua comandes, inventari i pagaments, i aquesta lliçó ha mostrat les dues maneres de viure sense ella. El commit en dues fases conserva l'atomicitat amb un coordinador que recull vots a la fase de prepare i difon la decisió a la de commit; PostgreSQL el suporta amb PREPARE TRANSACTION, i l'hem vist bloquejar files indefinidament quan el coordinador mor amb els participants preparats. Aquest bloqueig, juntament amb l'acoblament, la latència i la impossibilitat d'incloure passarel·les i brokers, és la raó per la qual els microserveis eviten 2PC (3PC no ajuda sense xarxa síncrona; el remei real és un coordinador replicat per consens, que només les bases de dades distribuïdes es permeten). La saga substitueix l'atomicitat per una seqüència de transaccions locals amb compensacions, ordenades en compensables, pivot i reintentables. L'hem implementada com a coreografia, amb consumidors idempotents i outbox encadenant comanda.creada, estoc.reservat, pagament.rebutjat i estoc.alliberat, i com a orquestració a saga_comanda.py, amb la taula sagas de km0_comandes persistint cada transició i un continuar reentrant que reprèn la saga després d'un reinici gràcies a la idempotència d'id_reserva i de la clau de cobrament heretades del Mòdul 2; la simulació ha compensat la reserva d'en Marc quan pagaments el va rebutjar i ha deixat l'estoc intacte. La taula de l'apartat 9 justifica l'elecció de l'orquestració per a la comanda de Quilòmetre Zero, i l'apartat 10 ha posat nom al que la saga perd, l'aïllament, amb contramesures (bloqueig semàntic, estats provisionals, lectures pessimistes, valors commutatius) i amb TCC com a variant per a recursos disputats.

Amb aquesta lliçó es tanca el Mòdul 3. Ja sabem dir amb precisió què garanteix un magatzem replicat (models de consistència), què cal sacrificar quan la xarxa es parteix i què costa la consistència quan no (CAP i PACELC), com un grup de nodes tria líder i acorda valors (Paxos, Raft, etcd), com es copien les dades entre nodes i amb quines anomalies (líder-seguidor, multilíder, quòrums), i com una operació de negoci travessa diversos serveis sense una transacció global (2PC i sagues). Tot això tracta les dades com si ja fossin en algun lloc. El Mòdul 4 s'ocupa d'aquest lloc: on i com es desen físicament les comandes, les fotos dels productes i els esdeveniments que ja sabem replicar i coordinar, quan són massa per a un sol node. Comença per la pregunta prèvia a qualsevol rèplica: com repartir dades diferents entre molts nodes, amb el particionament i el hashing consistent.

Curs d'Arquitectures Distribuïdes

Mòdul 1: Introducció als Sistemes Distribuïts

Mòdul 2: Comunicació en Sistemes Distribuïts

Mòdul 3: Consistència i Replicació

Mòdul 4: Emmagatzematge Distribuït

Mòdul 5: Computació Distribuïda

Mòdul 6: Seguretat en Sistemes Distribuïts

Mòdul 7: Monitoratge i Manteniment

Mòdul 8: Casos d'Estudi i Aplicacions

© Copyright 2026. Tots els drets reservats