La lliçó anterior va acabar amb un span en vermell: inventari-1 no respon i comandes ha obert el circuit cap a ell. L'observabilitat ha fet la seva feina, que era assenyalar; ara la plataforma ha de sobreviure. Això exigeix respondre preguntes que al monòlit ni es plantejaven: com distingir un node caigut d'un de lent, qui decideix que la rèplica passi a ser primària i com s'impedeix que l'antic primari torni creient que encara mana, com es reprèn un procés de dues hores que va caure al minut 90, i com es recuperen les dades de km0_inventari quan el que ha fallat no és un procés sinó un DELETE sense WHERE un divendres a la tarda. Aquesta lliçó recorre el cicle complet: detectar, tolerar (redundància i failover amb tancat), recuperar (checkpoints, còpies de seguretat, PITR, RPO/RTO, DR) i gestionar l'incident mentre tot això passa. Els patrons de crida entre serveis (timeouts, reintents, circuit breaker) són la lliçó 07-04; l'orquestració amb Kubernetes, la 07-05.

Contingut

  1. Fallades a la pràctica: del model teòric al que passa de debò
  2. Detecció: heartbeats, health checks i falsos positius
  3. Tolerància per redundància
  4. Failover, split-brain i tancat
  5. Failover real: Patroni, Kafka i Cassandra
  6. Recuperació d'estat: checkpoints de processos llargs
  7. Recuperació de dades: còpies de seguretat, PITR i proves de restauració
  8. RPO, RTO i pla de recuperació davant de desastres
  9. Gestió d'incidents: runbooks, on-call i postmortems
  10. Errors comuns i consells
  11. Exercicis i solucions
  12. Conclusió

  1. Fallades a la pràctica: del model teòric al que passa de debò

A 01-02 es van presentar els models de fallada: crash-stop, crash-recovery, omissió, temporització i bizantí. Serveixen per raonar sobre algorismes; en operació, el que es veu és la seva encarnació concreta, i gairebé mai no és el cas net:

El que passa Model de 01-02 Exemple a Quilòmetre Zero Per què és traïdor
Caiguda de node Crash-stop / crash-recovery Es mor el contenidor inventari-1 per OOM És el cas "fàcil": es detecta bé i hi ha rèplica
Partició de xarxa Omissió El switch entre les zones bcn i vlc perd paquets durant 40 s Cada costat creu que l'altre ha mort; tots dos continuen vius (03-02)
Disc ple Crash amb degradació prèvia El broker kafka-2 omple el disc amb repartiment.posicions sense retenció Abans de morir, escriu lent, timeouts als productors, lag als consumidors
Fallada grisa (lent però viu) Temporització comandes-db-primari respon, però cada consulta triga 4 s per un disc degradat Els health checks diuen "OK"; els clients esgoten els seus pools esperant (07-04)
Fallades correlacionades Diversos crash alhora Un canvi a la CA de Vault deixa sense certificat vàlid les tres rèpliques de comandes al mateix minut La redundància no protegeix: comparteixen la causa
Error humà Qualsevol DELETE FROM estoc WHERE productor_id = ... sense transacció ni WHERE correcte Es replica perfectament a inv-bcn i inv-vlc: la rèplica no és una còpia de seguretat
Desplegament defectuós Crash o gris, correlacionat Versió de comandes amb un bug que retorna 500 en el 8 % dels casos (el dissabte de 07-01) El rolling update el porta a totes les rèpliques; cal rollback (07-05)

Dues lliçons es repeteixen: les fallades lentes són pitjors que les fallades netes (un node mort se substitueix; un de lent contagia), i la redundància només protegeix contra fallades independents. Tot el que segueix intenta convertir fallades grises en fallades netes (detectar-les i apartar el node) i trencar les correlacions (zones, versions esglaonades, còpies de seguretat fora del sistema).

  1. Detecció: heartbeats, health checks i falsos positius

Un sistema no pot tolerar una fallada que no detecta. Hi ha dues direccions de detecció:

  • Heartbeats: el node vigilat envia periòdicament "soc viu" (a un coordinador, als seus parells, a etcd renovant un lease). Si deixa d'arribar, se'l dona per mort. És el que fan servir Raft (03-03), Cassandra (gossip), Kafka (els brokers amb el controlador) i Patroni (amb etcd).
  • Health checks: el vigilant pregunta "estàs bé?" al vigilat. És el que fan Kubernetes, els balancejadors i Kong amb els upstreams.

Tres tipus de health check

La distinció, popularitzada per Kubernetes (07-05), serveix per a qualsevol plataforma:

Sonda Pregunta Si falla Què ha de comprovar
Liveness (/salut/viu) "El procés està encallat sense remei?" Reiniciar el procés Només que el procés respon: un bucle intern bloquejat, memòria esgotada. Mai dependències externes
Readiness (/salut/preparat) "Pots atendre trànsit ara?" Treure'l del balanceig sense reiniciar-lo Dependències imprescindibles: connexió a km0_comandes, productor Kafka connectat, configuració carregada
Startup "Has acabat d'arrencar?" Esperar (no aplicar liveness encara) Migracions, memòries cau inicials, càrrega de certificats de Vault

L'error clàssic és comprovar PostgreSQL a la liveness: si la base de dades cau, totes les rèpliques de comandes fallen la sonda, es reinicien en bucle, i quan PostgreSQL torna, no hi ha ningú per atendre'l. Amb la readiness, en canvi, les rèpliques es retiren del balanceig, continuen vives, i tornen soles tan bon punt la dependència respon.

# km0/serveis/comandes/salut.py
"""Health checks de comandes: liveness sense dependències, readiness amb elles."""
import asyncio
import time

from fastapi import APIRouter, Response

router = APIRouter(prefix="/salut")
ARRENCADA = time.monotonic()


@router.get("/viu")
async def viu():
    # Liveness: si podem executar aquesta funció, el procés no està penjat.
    # Retornar 200 sempre; l'orquestrador el reiniciarà si ni tan sols respon.
    return {"estat": "viu", "segons_actiu": int(time.monotonic() - ARRENCADA)}


async def _comprovar_postgres(pool) -> tuple[bool, str]:
    try:
        async with pool.acquire(timeout=1.0) as conn:        # esperar com a màxim 1 s per una connexió
            await asyncio.wait_for(conn.execute("SELECT 1"), timeout=1.0)
        return True, "ok"
    except Exception as e:                                    # timeout, connexió rebutjada...
        return False, f"postgres: {type(e).__name__}"


async def _comprovar_kafka(productor) -> tuple[bool, str]:
    try:
        # list_topics amb timeout: si el clúster no respon, llança excepció
        await asyncio.get_running_loop().run_in_executor(
            None, lambda: productor.list_topics(topic="comandes.esdeveniments", timeout=1.0))
        return True, "ok"
    except Exception as e:
        return False, f"kafka: {type(e).__name__}"


@router.get("/preparat")
async def preparat(response: Response):
    from serveis.comandes.app import pool_pg, productor_kafka   # recursos del servei
    resultats = await asyncio.gather(_comprovar_postgres(pool_pg), _comprovar_kafka(productor_kafka))
    detall = {"postgres": resultats[0][1], "kafka": resultats[1][1]}
    if all(ok for ok, _ in resultats):
        return {"estat": "preparat", **detall}
    response.status_code = 503        # el balancejador deixa d'enviar-nos trànsit
    return {"estat": "no_preparat", **detall}

Els timeouts d'1 s a cada comprovació formen part del disseny: un health check sense timeout que es queda esperant un PostgreSQL gris fa que la mateixa sonda sigui lenta, i l'orquestrador la interpreta com a fallada. Amb timeouts, un PostgreSQL gris es converteix en "no preparat" en un segon, que és just la conversió de fallada grisa en fallada neta que es buscava.

Timeouts de detecció i falsos positius

Tot detector basat en temps té una tensió: un llindar curt detecta ràpid però declara morts nodes vius que només anaven lents (fals positiu); un de llarg triga a reaccionar. En una xarxa amb partició, a més, és impossible distingir "mort" d'"inabastable" (03-02), així que la pregunta correcta no és "està mort?" sinó "quanta confiança tinc que ho està?". El detector phi-accrual (Hayashibara et al., usat per Cassandra i Akka) respon amb un número: en lloc d'un llindar fix, aprèn la distribució dels intervals entre heartbeats i calcula la sospita φ com "com d'improbable és aquest retard donat el que he vist". Una versió simplificada:

# km0/simulacions/heartbeat_detector.py
"""Detector de fallades phi-accrual simplificat.

Cada node envia heartbeats; el detector aprèn la mitjana i la desviació dels
intervals i expressa la sospita com phi = -log10(P(retard >= observat)).
phi = 1 -> 10 % de probabilitat que sigui un retard normal; phi = 3 -> 0,1 %.
"""
import math
import statistics
import time
from collections import deque


class DetectorPhi:
    def __init__(self, llindar_phi: float = 8.0, finestra: int = 100, interval_inicial: float = 1.0):
        self.llindar = llindar_phi
        self.intervals: dict[str, deque] = {}
        self.ultim: dict[str, float] = {}
        self.finestra = finestra
        self.interval_inicial = interval_inicial

    def heartbeat(self, node: str, ara: float | None = None) -> None:
        ara = ara or time.monotonic()
        if node in self.ultim:
            self.intervals.setdefault(node, deque(maxlen=self.finestra)).append(ara - self.ultim[node])
        self.ultim[node] = ara

    def phi(self, node: str, ara: float | None = None) -> float:
        ara = ara or time.monotonic()
        if node not in self.ultim:
            return 0.0
        mostres = self.intervals.get(node)
        if not mostres or len(mostres) < 2:
            mitjana, desv = self.interval_inicial, self.interval_inicial / 4
        else:
            mitjana = statistics.mean(mostres)
            desv = max(statistics.pstdev(mostres), mitjana * 0.05)   # evitar desviació 0
        retard = ara - self.ultim[node]
        # Aproximació amb distribució normal: P(X >= retard)
        z = (retard - mitjana) / desv
        p = 0.5 * math.erfc(z / math.sqrt(2))
        return -math.log10(max(p, 1e-12))     # cota per no dividir per 0

    def sospitos(self, node: str, ara: float | None = None) -> bool:
        return self.phi(node, ara) > self.llindar


if __name__ == "__main__":
    # Simulació: inv-bcn envia heartbeats cada 1 s amb jitter; als 30 s calla.
    import random
    det = DetectorPhi(llindar_phi=8.0)
    t = 0.0
    for i in range(30):
        det.heartbeat("inv-bcn", ara=t)
        t += 1.0 + random.gauss(0, 0.05)
    ultim_hb = t
    for retard in (0.5, 1.0, 1.2, 1.5, 2.0, 3.0, 5.0):
        ara = ultim_hb + retard
        print(f"retard {retard:4.1f} s  phi = {det.phi('inv-bcn', ara):6.2f}  "
              f"{'SOSPITOS' if det.sospitos('inv-bcn', ara) else 'viu'}")

Sortida típica:

retard  0.5 s  phi =   0.00  viu
retard  1.0 s  phi =   0.30  viu
retard  1.2 s  phi =   3.90  viu
retard  1.5 s  phi =  12.00  SOSPITOS
retard  2.0 s  phi =  12.00  SOSPITOS
...

Amb una xarxa molt estable (desviació de 50 ms), 1,5 s de silenci ja és altament sospitós; en una xarxa amb jitter de 300 ms, el mateix detector esperaria més abans de sospitar, sense canviar cap paràmetre. Aquest és el valor d'un detector adaptatiu: el llindar s'ajusta al comportament observat, i el consumidor del detector (qui decideix el failover) tria quanta confiança exigeix. El gossip és la manera de propagar aquest estat entre molts nodes sense un coordinador: cada node explica a uns pocs, a l'atzar, el que sap dels altres (Cassandra el fa servir per a pertinença i estat). N'hi ha prou de saber que existeix.

  1. Tolerància per redundància

Detectada la fallada, tolerar-la exigeix tenir amb què substituir el que falla:

Esquema Què és Cost Exemple a Quilòmetre Zero
N+1 Capacitat per a la càrrega amb N unitats, més una de reserva 1/N extra comandes amb 3 rèpliques quan 2 en són prou per a la Setmana de la Verema
Actiu-actiu Totes les unitats atenen trànsit alhora Tot treballa; cal que siguin intercanviables (sense estat o amb estat replicat) Rèpliques de comandes, cataleg, inventari darrere de Kong; nodes de Cassandra
Actiu-passiu Una unitat atén; una altra espera preparada per rellevar-la La passiva està ociosa; hi ha un instant de commutació comandes-db-primari / comandes-db-replica; el líder del relay outbox amb lease a etcd
Zones de disponibilitat Unitats repartides per dominis de fallada independents (edifici, alimentació, xarxa) Latència entre zones; cost de duplicar inv-bcn en una zona, inv-vlc en una altra; brokers de Kafka repartits amb rack awareness

El que fa útil la redundància és la independència: tres rèpliques de comandes a la mateixa màquina no toleren que caigui la màquina; tres nodes de Cassandra al mateix rack no toleren que caigui el switch. I, com va mostrar la taula de fallades, tres rèpliques amb el mateix certificat caducat no toleren res. La redundància es dissenya per domini de fallada: màquina, rack, zona, versió de programari, credencial.

  1. Failover, split-brain i tancat

El failover és la commutació d'un component fallit al seu substitut. Per a serveis sense estat és trivial: el balancejador deixa d'enviar a la rèplica no preparada (readiness) i prou. Per a components amb estat i un sol escriptor (el primari de PostgreSQL, el líder d'una partició de Kafka, el líder del relay outbox) és el problema més delicat de tota la lliçó, i 03-04 el va deixar anunciat: split-brain.

L'escenari: inv-bcn és primari i inv-vlc la seva rèplica en streaming. Una partició de xarxa de 40 s separa inv-bcn de tota la resta. El detector, des de l'altre costat, el dona per mort i promociona inv-vlc. Però inv-bcn no és mort: continua acceptant escriptures dels clients que encara l'abasten. Quan la xarxa torna, hi ha dos primaris amb històries divergents: dues reserves de l'últim formatge-curat en dues bases de dades que ja no es poden reconciliar automàticament.

Les defenses, per ordre de fortalesa:

  1. Consens per a la decisió: no promociona "qui el veu mort", sinó una majoria a través d'un magatzem de consens (etcd, 03-03). Com a màxim un node pot tenir el lease de "líder" alhora.
  2. Tancat (fencing): garantir que l'antic primari no pot escriure abans que el nou comenci. Hi ha diverses maneres, i es combinen:
    • Token de tancat: cada líder rep un número monòton creixent (la revision del lease d'etcd, com a 03-03). Qualsevol recurs compartit (l'emmagatzematge, el consumidor de l'outbox) rebutja escriptures amb un token menor que l'últim que ha vist. El líder vell, amb el token 41, no pot escriure on ja s'ha vist el 42.
    • Autotancat: el líder es degrada sol si no aconsegueix renovar el seu lease (per això el relay outbox comprova el seu lease abans de cada lot i s'atura si no el té). Depèn que el rellotge del líder no estigui gaire desviat (01-05).
    • STONITH ("shoot the other node in the head"): apagar físicament el node vell (per IPMI, per l'API del núvol) abans de promocionar. Brutal, però definitiu.
  3. Timeouts generosos i for a la decisió: no promocionar per un parpelleig de 3 s.
sequenceDiagram
    participant C as Clients (inventari)
    participant A as inv-bcn (primari, lease rev 41)
    participant E as etcd (3 nodes)
    participant B as inv-vlc (replica)
    Note over A,E: Partició: inv-bcn no abasta etcd
    A--xE: renovar lease (falla)
    Note over A: TTL esgotat sense renovar:<br/>AUTOTANCAT -> mode només lectura
    E->>B: lease de líder expirat
    B->>E: adquirir lease (rev 42)
    E-->>B: concedit
    Note over B: promote: pg_promote()
    B->>C: "soc primari, token 42"
    C->>B: escriptures amb token 42
    Note over A,E: La xarxa torna
    A->>C: escriptura amb token 41
    C-->>A: rebutjada (41 < 42)
    A->>E: qui és líder?
    E-->>A: inv-vlc (42)
    Note over A: es reincorpora com a rèplica<br/>(pg_rewind si ha divergit)

El failback (tornar al node original quan es recupera) rarament val la pena de manera automàtica: cada commutació és un risc. L'habitual és que el recuperat s'incorpori com a rèplica i s'hi quedi fins a un switchover planificat.

  1. Failover real: Patroni, Kafka i Cassandra

5.1 Patroni per a PostgreSQL

Patroni és un agent que corre al costat de cada PostgreSQL i fa exactament el que s'ha descrit: fa servir etcd (o Consul, o l'API de Kubernetes) com a magatzem de consens, manté un lease de líder amb TTL, promociona la rèplica més avançada quan el lease expira, i degrada el primari vell que no aconsegueix renovar. Per a km0_inventari, el docker-compose.yml:

# km0/docker-compose.yml (fragment): Patroni amb 3 nodes per a km0_inventari
  etcd-1:
    image: quay.io/coreos/etcd:v3.5.15
    command: ["etcd", "--name=etcd-1", "--initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380",
              "--listen-peer-urls=http://0.0.0.0:2380", "--listen-client-urls=http://0.0.0.0:2379",
              "--advertise-client-urls=http://etcd-1:2379", "--initial-advertise-peer-urls=http://etcd-1:2380"]
  # etcd-2 i etcd-3 iguals canviant el nom

  inv-bcn: &patroni
    image: ghcr.io/zalando/spilo-16:3.3-p1      # PostgreSQL 16 + Patroni
    environment: &patroni_env
      SCOPE: km0-inventari                       # nom del clúster a etcd
      ETCD3_HOSTS: "etcd-1:2379,etcd-2:2379,etcd-3:2379"
      PGPASSWORD_SUPERUSER_FILE: /run/secrets/pg_super
      PATRONI_TTL: "30"                          # lease del líder: 30 s
      PATRONI_LOOP_WAIT: "10"                    # cada 10 s renova
      PATRONI_RETRY_TIMEOUT: "10"
      PATRONI_MAXIMUM_LAG_ON_FAILOVER: "1048576" # no promocionar una rèplica amb > 1 MB de retard
      PATRONI_SYNCHRONOUS_MODE: "true"           # almenys una rèplica síncrona: RPO 0 en failover
    hostname: inv-bcn
    volumes: ["inv-bcn-dades:/home/postgres/pgdata"]
  inv-vlc:
    <<: *patroni
    hostname: inv-vlc
    volumes: ["inv-vlc-dades:/home/postgres/pgdata"]
  inv-gir:
    <<: *patroni
    hostname: inv-gir
    volumes: ["inv-gir-dades:/home/postgres/pgdata"]

  # Els clients no saben qui és primari: HAProxy pregunta a Patroni (/primary retorna 200 només al líder)
  inventari-db:
    image: haproxy:2.9
    volumes: ["./observabilitat/haproxy-patroni.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro"]
    ports: ["5432:5432", "5433:5433"]     # 5432 -> primari (escriptura); 5433 -> rèpliques (lectura)

Els paràmetres que importen: TTL i LOOP_WAIT defineixen la finestra de detecció (fins a 30 s sense renovar = lease perdut); MAXIMUM_LAG_ON_FAILOVER evita promocionar una rèplica molt endarrerida (perdria més dades de les acceptables); SYNCHRONOUS_MODE fa que cada commit esperi una rèplica, de manera que un failover no perd transaccions confirmades (el preu és latència d'escriptura, la mateixa tensió de 03-01). HAProxy consulta l'endpoint REST de Patroni per saber a qui enviar, de manera que inventari es connecta sempre a inventari-db:5432 i mai no necessita saber qui és primari.

Operació del dia a dia:

$ patronictl -c /etc/patroni.yml list
+ Cluster: km0-inventari ------+---------+-----------+----+-----------+
| Member  | Host     | Role    | State     | TL | Lag in MB |
+---------+----------+---------+-----------+----+-----------+
| inv-bcn | inv-bcn  | Leader  | running   | 12 |           |
| inv-vlc | inv-vlc  | Sync Standby | streaming | 12 |     0 |
| inv-gir | inv-gir  | Replica | streaming | 12 |         0 |
+---------+----------+---------+-----------+----+-----------+

# Commutació planificada (manteniment d'inv-bcn): sense pèrdua, amb confirmació
$ patronictl -c /etc/patroni.yml switchover --leader inv-bcn --candidate inv-vlc --scheduled now
Are you sure you want to switchover cluster km0-inventari, demoting current leader inv-bcn? [y/N]: y
Successfully switched over to "inv-vlc"

# Després d'un failover no planificat, el líder vell es reincorpora; si ha divergit, Patroni executa pg_rewind
$ patronictl -c /etc/patroni.yml list
| inv-bcn | inv-bcn  | Replica | streaming | 13 |         0 |
| inv-vlc | inv-vlc  | Leader  | running   | 13 |           |

La columna TL (timeline) puja amb cada promoció: és el "token de tancat" de PostgreSQL. Un primari a la timeline 12 no pot enviar WAL a rèpliques que ja són a la 13.

5.2 Kafka: rèpliques de partició, ISR i elecció de líder

Kafka no té "un primari": cada partició té un líder i N-1 rèpliques seguidores, repartides per brokers. El conjunt de rèpliques al dia s'anomena ISR (in-sync replicas). Quan cau el broker líder d'una partició, el controlador del clúster tria un nou líder entre les ISR; els productors i consumidors descobreixen el canvi a la següent petició de metadades. Els paràmetres que governen què es perd:

Paràmetre Valor a Quilòmetre Zero Efecte
replication.factor 3 (comandes.esdeveniments, auditoria.esdeveniments), 2 (repartiment.posicions) Quantes còpies de cada partició
min.insync.replicas 2 Un acks=all només es confirma si almenys 2 rèpliques el tenen; si només queda 1 ISR, el productor rep NotEnoughReplicas (es prefereix no acceptar a perdre)
acks (productor) all a comandes, 1 a repartiment Quan considera el productor que el missatge està desat
unclean.leader.election.enable false Mai triar com a líder una rèplica fora de l'ISR: millor partició no disponible que perdre missatges confirmats

La combinació replication.factor=3 + min.insync.replicas=2 + acks=all tolera la caiguda d'un broker sense perdre ni un esdeveniment de comanda confirmada; amb dos brokers caiguts, comandes.esdeveniments deixa d'acceptar escriptures (i l'outbox de 02-05 les reté fins que torni). repartiment.posicions accepta perdre posicions a canvi de latència.

5.3 Cassandra: sense líder a commutar

km0_comandes a Cassandra (04-04) no necessita failover perquè no hi ha líder: cada fila viu en 3 nodes i cada lectura i escriptura amb LOCAL_QUORUM en necessita 2. Si cau un node, les operacions continuen amb els altres dos; el detector phi-accrual i el gossip marquen el node caigut; quan torna, els hinted handoffs (escriptures desades per a ell pels seus veïns) i la reparació (nodetool repair) el posen al dia. El preu ja es va pagar en el disseny: consistència eventual entre rèpliques i un model de dades per consultes.

  1. Recuperació d'estat: checkpoints de processos llargs

No tot són bases de dades. Cada nit, inventari executa la reconciliació d'estoc: recorre els tres productors, compara l'estoc de km0_inventari amb les reserves confirmades a km0_comandes i els lliuraments de repartiment, i corregeix les desviacions. Triga unes dues hores. Si el procés mor als 90 minuts (OOM, desplegament, node caigut), començar de zero significa dues hores més, i potser no acaba abans que obrin els mercats. La solució és la mateixa idea que els checkpoints de Flink (05-04): persistir el progrés periòdicament en un lloc que sobrevisqui al procés, i reprendre des de l'últim checkpoint de manera idempotent.

# km0/serveis/inventari/reconciliacio_nocturna.py
"""Reconciliació nocturna d'estoc amb checkpoint persistit i represa."""
import json
import time
from dataclasses import dataclass, asdict

import psycopg
from serveis.comu.logs import log

CHECKPOINT_CADA = 500          # productes processats entre checkpoints


@dataclass
class Checkpoint:
    execucio_id: str           # p. ex. "2026-09-13"
    productor_actual: str      # slug del productor que s'està processant
    ultim_producte: str        # últim producte confirmat dins d'aquest productor ("" = cap)
    processats: int
    corregits: int


def carregar_checkpoint(conn, execucio_id: str) -> Checkpoint | None:
    fila = conn.execute(
        "SELECT estat FROM reconciliacio_checkpoints WHERE execucio_id = %s", (execucio_id,)
    ).fetchone()
    return Checkpoint(**json.loads(fila[0])) if fila else None


def desar_checkpoint(conn, cp: Checkpoint) -> None:
    # UPSERT: la fila del dia se sobreescriu; es confirma a la mateixa transacció
    # que les correccions del lot, així mai no hi ha correccions sense checkpoint ni a l'inrevés.
    conn.execute(
        """INSERT INTO reconciliacio_checkpoints (execucio_id, estat, actualitzat)
           VALUES (%s, %s, now())
           ON CONFLICT (execucio_id) DO UPDATE SET estat = EXCLUDED.estat, actualitzat = now()""",
        (cp.execucio_id, json.dumps(asdict(cp))),
    )


def productes_des_de(conn, productor: str, ultim_producte: str):
    # Ordre determinista per slug: reprendre és "continuar a partir de l'últim confirmat"
    yield from conn.execute(
        "SELECT slug FROM productes WHERE productor = %s AND slug > %s ORDER BY slug",
        (productor, ultim_producte),
    )


def reconciliar_producte(conn, productor: str, producte: str) -> bool:
    """Compara estoc amb reserves i lliuraments; corregeix si hi ha desviació. Idempotent:
    executar-ho dues vegades sobre el mateix producte deixa el mateix resultat."""
    esperat = calcular_estoc_esperat(conn, productor, producte)     # consulta a km0_comandes i repartiment
    actual = conn.execute("SELECT unitats FROM estoc WHERE producte = %s FOR UPDATE", (producte,)).fetchone()[0]
    if actual != esperat:
        conn.execute("UPDATE estoc SET unitats = %s WHERE producte = %s", (esperat, producte))
        log.warning("estoc_corregit", productor=productor, producte=producte, abans=actual, despres=esperat)
        return True
    return False


def executar(execucio_id: str, productors: list[str]) -> None:
    t0 = time.monotonic()
    with psycopg.connect(DSN_INVENTARI) as conn:
        cp = carregar_checkpoint(conn, execucio_id) or Checkpoint(execucio_id, productors[0], "", 0, 0)
        if cp.processats:
            log.info("reconciliacio_represa", des_de_productor=cp.productor_actual,
                     des_de_producte=cp.ultim_producte, processats=cp.processats)
        # Saltar els productors ja completats
        for productor in productors[productors.index(cp.productor_actual):]:
            des_de = cp.ultim_producte if productor == cp.productor_actual else ""
            pendent_en_lot = 0
            for (producte,) in productes_des_de(conn, productor, des_de):
                if reconciliar_producte(conn, productor, producte):
                    cp.corregits += 1
                cp.processats += 1
                cp.productor_actual, cp.ultim_producte = productor, producte
                pendent_en_lot += 1
                if pendent_en_lot >= CHECKPOINT_CADA:
                    desar_checkpoint(conn, cp)
                    conn.commit()            # correccions + checkpoint, atòmicament
                    pendent_en_lot = 0
            desar_checkpoint(conn, cp)
            conn.commit()
        log.info("reconciliacio_acabada", processats=cp.processats, corregits=cp.corregits,
                 durada_s=int(time.monotonic() - t0))


if __name__ == "__main__":
    executar(time.strftime("%Y-%m-%d"), ["horta-la-vega", "formatgeria-montblanc", "celler-roure-alt"])

Les tres propietats que fan que això funcioni, i que serveixen per a qualsevol procés llarg:

  1. El checkpoint es confirma a la mateixa transacció que la feina que representa. Si el procés mor entre l'UPDATE i el checkpoint, la transacció no es confirma i tots dos es perden junts; mai no queda un checkpoint que digui "fins a formatge-curat" amb formatge-curat sense corregir, ni a l'inrevés.
  2. L'ordre és determinista (ORDER BY slug), de manera que "reprendre des de X" té significat.
  3. Cada unitat de feina és idempotent: si el checkpoint es va desar abans d'un lot de 499 productes i el procés va morir, aquests 499 es reprocessen en reprendre, i reprocessar-los no fa cap mal.

Airflow (05-05) és qui llança aquest procés i qui el rellança si falla (retries): la represa des del checkpoint fa que el reintent costi minuts i no hores.

  1. Recuperació de dades: còpies de seguretat, PITR i proves de restauració

La replicació protegeix contra la caiguda d'un node; no protegeix contra una dada dolenta. El DELETE erroni de la secció 1 arriba a inv-vlc en mil·lisegons. Per a això hi ha les còpies de seguretat, que són còpies desacoblades en el temps del sistema.

Tipus Com Avantatges Inconvenients A Quilòmetre Zero
Lògica pg_dump: bolcat SQL o format propi Portable entre versions; restaurar una taula solta Lent en bases grans; restaurar és reexecutar; no permet PITR km0_analitica setmanal (es pot regenerar del llac)
Física pg_basebackup: còpia dels fitxers de dades Ràpida; base per al PITR Mateixa versió major; tot o res km0_inventari diària
PITR (point-in-time recovery) Còpia física + arxivament continu del WAL Restaurar a qualsevol instant (les 16:59, un minut abans del DELETE) Cal desar tot el WAL des de l'última còpia base km0_inventari amb WAL a km0-backups
Snapshot Còpia dels SSTables de Cassandra (nodetool snapshot), enllaços durs instantanis Gairebé gratis de crear Cal copiar-los fora del node; per node km0_comandes diari, a km0-backups
Versionat d'objectes MinIO conserva versions anteriors de cada objecte Un esborrat o una sobreescriptura es desfà Cost d'emmagatzematge km0-factures, km0-auditoria (amb object lock, 06-05)

PITR a PostgreSQL pas a pas

PostgreSQL escriu cada canvi primer al WAL (write-ahead log), en segments de 16 MB. Si es desen tots els segments des d'una còpia base, es pot reproduir la història fins a l'instant desitjat.

flowchart LR
    subgraph normal["Operació normal"]
        PG[(inv-bcn<br/>primari)] -- "archive_command<br/>cada segment WAL" --> M[(MinIO<br/>km0-backups/inventari/wal/)]
        PG -- "pg_basebackup<br/>diari 02:00" --> MB[(km0-backups/inventari/base/2026-09-12/)]
    end
    subgraph recuperacio["Recuperació a les 16:59"]
        MB --> R[Node de restauració]
        M -- "restore_command<br/>replay fins a recovery_target_time" --> R
        R --> V{dades correctes?}
        V -- sí --> P[pg_promote → nou primari<br/>Patroni reinicialitza rèpliques]
    end

Configuració de l'arxivament al primari (Patroni l'aplica a postgresql.parameters):

# km0/sql/backup/arxivar_wal.sh — invocat per PostgreSQL amb %p (ruta) i %f (nom del segment)
#!/usr/bin/env bash
set -euo pipefail
# mc: client de MinIO; l'àlies 'km0' es va configurar amb credencials de Vault (06-04)
mc cp --quiet "$1" "km0/km0-backups/inventari/wal/$2"
# Paràmetres de PostgreSQL gestionats per Patroni (fragment de patroni.yml)
postgresql:
  parameters:
    wal_level: replica
    archive_mode: "on"
    archive_command: "/opt/km0/sql/backup/arxivar_wal.sh %p %f"
    archive_timeout: 60        # forçar un segment cada minut encara que no sigui ple: RPO <= 1 min

Còpia base diària:

# km0/sql/backup/base_backup.sh
#!/usr/bin/env bash
set -euo pipefail
DATA=$(date -u +%F)
DESTI=/backups/base/$DATA
mkdir -p "$DESTI"
# -Ft: tar; -z: comprimit; -X stream: inclou el WAL necessari perquè la còpia sigui consistent per si mateixa
pg_basebackup -h inventari-db -p 5432 -U replicador -D "$DESTI" -Ft -z -X stream --checkpoint=fast
mc cp --recursive "$DESTI" "km0/km0-backups/inventari/base/$DATA/"
# Conservar 14 dies de còpies base; el WAL anterior a la més antiga ja no serveix
mc rm --recursive --force --older-than 14d km0/km0-backups/inventari/base/

I la restauració a les 16:59 del divendres, un minut abans del DELETE de les 17:00:

# 1. Node net (inv-rest): descarregar l'última còpia base ANTERIOR a l'instant objectiu
mc cp --recursive km0/km0-backups/inventari/base/2026-09-11/ /restauracio/base/
mkdir -p /restauracio/pgdata && cd /restauracio/pgdata
tar -xzf /restauracio/base/base.tar.gz
tar -xzf /restauracio/base/pg_wal.tar.gz -C pg_wal/

# 2. Dir a PostgreSQL fins on reproduir i d'on treure el WAL
cat >> postgresql.auto.conf <<'EOF'
restore_command = 'mc cp --quiet km0/km0-backups/inventari/wal/%f %p'
recovery_target_time = '2026-09-11 16:59:00+02'
recovery_target_action = 'promote'      # en arribar-hi, sortir del mode recuperació
EOF
touch recovery.signal

# 3. Arrencar: reprodueix el WAL del dia fins a les 16:59 i es promociona
pg_ctl -D /restauracio/pgdata start
# LOG:  starting point-in-time recovery to 2026-09-11 16:59:00+02
# LOG:  restored log file "000000010000004A000000F3" from archive
# ...
# LOG:  recovery stopping before commit of transaction 8812345, time 2026-09-11 16:59:12.8
# LOG:  database system is ready to accept connections

# 4. Verificar abans de tocar producció
psql -d km0_inventari -c "SELECT count(*), sum(unitats) FROM estoc WHERE productor = 'formatgeria-montblanc';"

# 5. Decidir: (a) extreure les files esborrades i reinserir-les a producció amb un script (l'habitual,
#    si producció ha continuat rebent comandes després de les 17:00), o
#    (b) convertir inv-rest en el nou primari del clúster Patroni i reinicialitzar les rèpliques
#    (si el dany és tan gran que es prefereix perdre el posterior a les 16:59).

El pas 5 és el que s'oblida als exercicis de manual: entre les 17:00 i el moment de la restauració, km0_inventari ha continuat rebent reserves. Tornar sencera a les 16:59 les perdria. Gairebé sempre s'opta per restaurar a part i reinjectar el que faltava.

Cassandra i MinIO

# Snapshot a cada node de km0_comandes (enllaços durs als SSTables actuals: instantani)
nodetool snapshot -t diari-2026-09-12 km0_comandes
# Copiar fora del node (per keyspace/taula) i esborrar el snapshot local
mc cp --recursive /var/lib/cassandra/data/km0_comandes/*/snapshots/diari-2026-09-12/ \
      km0/km0-backups/cassandra/$(hostname)/2026-09-12/
nodetool clearsnapshot -t diari-2026-09-12
# Restaurar: copiar els SSTables al directori de la taula i executar `nodetool refresh km0_comandes comandes`
# (o carregar-los amb sstableloader en un clúster diferent)

# MinIO: activar el versionat als buckets on l'esborrat accidental importi
mc version enable km0/km0-factures
mc undo km0/km0-factures/2026/09/F-2026-004411.pdf   # desfer l'últim esborrat o sobreescriptura

Proves de restauració

Una còpia de seguretat que mai no s'ha restaurat no és una còpia de seguretat: és una esperança. Les fallades habituals són de les més prosaiques: l'archive_command fa tres setmanes que falla en silenci (alerta: pg_stat_archiver.failed_count puja, i és la mètrica pg_stat_archiver_failed_count de postgres_exporter); la còpia base està corrupta; la restauració necessita un paràmetre que ningú no recorda; triga sis hores i l'RTO era d'una. Per això Quilòmetre Zero té un DAG d'Airflow, km0_prova_restauracio, que cada diumenge restaura l'última còpia de km0_inventari en un contenidor efímer, executa unes consultes de verificació (nombre de productes per productor, suma d'estoc) contra el que es va registrar en el moment de la còpia, mesura el temps, i publica km0_backup_restauracio_ok{bd="inventari"} i km0_backup_restauracio_segons a Prometheus. Si falla, és una alerta de ticket amb la mateixa prioritat que una fallada de producció.

  1. RPO, RTO i pla de recuperació davant de desastres

Dos números resumeixen el que un sistema promet davant d'un desastre:

  • RPO (Recovery Point Objective): quantes dades, mesurades en temps, s'accepta perdre. Un RPO d'1 minut significa que l'última còpia utilitzable té com a màxim un minut d'antiguitat.
  • RTO (Recovery Time Objective): quant de temps pot estar el servei caigut fins a recuperar-se.

Tots dos es fixen per negoci i es paguen en arquitectura: RPO 0 exigeix replicació síncrona; RTO de minuts exigeix failover automàtic i provat. Per a Quilòmetre Zero:

Component RPO RTO Com s'aconsegueix Què es perd si se supera
km0_inventari (PostgreSQL) 0 en failover (rèplica síncrona); 1 min en PITR (archive_timeout) 1 min (Patroni); 1 h (PITR) Patroni amb 3 nodes; WAL a km0-backups; prova setmanal Reserves d'estoc: sobrevenda als productors
km0_comandes (Cassandra, 3 nodes) 0 amb un node caigut; 24 h davant de pèrdua total (snapshot diari) 0 amb un node caigut; 4 h davant de pèrdua total RF 3 + LOCAL_QUORUM; snapshots a MinIO; els esdeveniments de comandes.esdeveniments permeten reconstruir Comandes: el dany més directe als clients
comandes-db-* (PostgreSQL, streaming) segons (rèplica asíncrona) 5 min (promoció manual documentada) Streaming + runbook Estat de sagues: compensacions pendents
Kafka (comandes.esdeveniments, auditoria.esdeveniments) 0 (RF 3, min.insync.replicas 2, acks=all) 30 s (elecció de líder) Configuració de tòpics Esdeveniments: analítica i auditoria incompletes
Kafka (repartiment.posicions) minuts (RF 2, acks=1) 30 s S'accepta: les posicions són efímeres Res de rellevant
Redis (memòria cau) ∞ (es regenera) 1 min Sense còpia; el catàleg es reescalfa (04-05) Un pic de càrrega a PostgreSQL
MinIO km0-factures, km0-auditoria 0 (versionat, object lock, rèplica de bucket a una altra regió) 1 h Replicació de bucket Obligacions legals
km0_analitica 24 h 8 h Bolcat setmanal + reexecució del DAG des del llac Informes: es recalculen

L'última línia de defensa és el pla de recuperació davant de desastres (DR): què fer si desapareix un centre de dades sencer (incendi, tall elèctric prolongat, error del proveïdor). Les estratègies, de més barata a més cara:

Estratègia Què hi ha a la regió secundària RPO / RTO típics Cost
Còpia i restaurar Només les còpies de seguretat (km0-backups replicat) Hores / hores-dies Mínim
Pilot light Dades replicades en calent (rèpliques de PostgreSQL, mirror de Kafka); serveis apagats, preparats per arrencar Minuts / desenes de minuts Baix
Warm standby Tot desplegat a escala reduïda i rebent rèplica; s'escala en commutar Segons-minuts / minuts Mitjà
Multiregió activa Totes dues regions atenen trànsit; dades replicades en tots dos sentits ~0 / ~0 Alt, i complex (conflictes, 03-04)

Quilòmetre Zero opta per pilot light: rèplica de bucket de MinIO a la regió secundària, una rèplica de PostgreSQL de cada clúster a l'altra regió (asíncrona, fora del quòrum de Patroni) i un mirror dels tòpics crítics de Kafka. On viu aquesta regió secundària i com es desplega amb serveis gestionats és matèria de 08-03. El que sí que és d'aquesta lliçó: el pla s'assaja (un game day al semestre, 07-06), té un runbook, i el criteri per activar-lo està escrit per endavant, perquè enmig d'un desastre ningú no raona bé.

  1. Gestió d'incidents: runbooks, on-call i postmortems

Tot l'anterior són mecanismes; els incidents els gestionen persones sota pressió. Tres pràctiques breus:

Runbooks. Cada alerta de pàgina (07-01) enllaça a un document amb: què significa, com confirmar-ho, què mirar (panells i consultes concretes), accions ordenades de menys a més invasives, i quan escalar. El runbook de ComandesBurnRateRapid comença amb "hi ha hagut un desplegament en la darrera hora? Si sí, rollback primer, investigar després". S'escriuen en fred i es corregeixen després de cada ús.

On-call. Algú (en Jordi o la Marta aquesta setmana) rep les pàgines, amb un secundari de suport, rotació setmanal, i compensació. La càrrega es mesura: més de dues pàgines per nit és un problema del sistema, no de la persona. Un incident té un coordinador (que comunica i decideix) separat de qui investiga, i un canal amb un registre de temps, que serà la matèria primera del postmortem.

Postmortems sense culpa. Després de cada incident rellevant: cronologia, impacte (en termes d'SLO i pressupost d'error), causes contribuents (en plural: gairebé mai no n'és una), què va funcionar, què no, i accions amb responsable i data. "Sense culpa" no és cortesia: si assenyalar qui va executar el DELETE fos el resultat, la propera vegada ningú no explicarà què va passar, i el sistema que va permetre executar un DELETE sense transacció a producció continuarà igual. L'acció correcta és "les sessions d'operador a km0_inventari arrenquen amb SET default_transaction_read_only = on" i "PITR provat setmanalment", no "més cura".

Errors Comuns i Consells

  • Comprovar dependències a la liveness. Reinicia en bucle totes les rèpliques quan cau PostgreSQL. Liveness: només el procés. Readiness: les dependències, amb timeouts.
  • Health checks sense timeout. Una dependència grisa fa lenta la sonda i l'orquestrador la interpreta com a fallada, però tard i malament. Cada comprovació amb el seu propi límit d'1 s.
  • Promocionar sense tancat. "El veiem caigut, promocionem" és la recepta del split-brain. Consens per decidir (etcd/Patroni), token o timeline per rebutjar el vell, autotancat per lease.
  • Creure que la rèplica és una còpia de seguretat. Replica els errors tan bé com els encerts. Còpia física + WAL arxivat, fora del sistema, amb retenció.
  • Còpies de seguretat que mai no es restauren. Són una esperança. Prova de restauració automatitzada, mesurada i alertada.
  • Restaurar sencera a un instant passat sense pensar en el posterior. Es perden les transaccions legítimes des d'aleshores. Restaurar a part i reinjectar.
  • RPO/RTO sense escriure. Si no estan escrits, el sistema té els que surtin. Taula per component, acordada amb negoci, i arquitectura que la compleixi.
  • Redundància sense independència. Tres rèpliques a la mateixa màquina, amb el mateix certificat, desplegades alhora. Dominis de fallada diferents per a cada eix.
  • Postmortem que acaba en "més cura". No és una acció. Cada causa contribuent té un canvi al sistema amb responsable i data.

Exercicis

Exercici 1. Durant la Setmana de la Verema, el disc d'inv-bcn comença a degradar-se: continua responent, però cada UPDATE triga 3-4 s. Patroni renova el lease sense problemes (etcd respon), /salut/preparat d'inventari continua retornant 200 perquè el SELECT 1 triga 20 ms, i les alertes de latència de comandes salten. (a) Quin tipus de fallada és i per què cap dels dos detectors no la veu? (b) Proposa un canvi a la readiness d'inventari i un altre a la configuració de Patroni o a l'operativa que converteixin aquesta fallada grisa en una commutació neta, i comenta el risc de falsos positius de cadascun. (c) Després del switchover a inv-vlc, què passa amb les transaccions que inv-bcn tenia a mitges, i què garanteix que cap reserva confirmada a comandes no es perdi?

Exercici 2. El divendres a les 17:00 un operador executa a km0_inventari un UPDATE estoc SET unitats = 0 WHERE productor = 'celler-roure-alt' creient que és a l'entorn de proves. Es detecta a les 17:25 per l'alerta de negoci "reserves rebutjades per estoc" (07-01). L'última còpia base és de les 02:00 i el WAL s'arxiva cada minut. (a) Descriu el procediment complet, amb les ordres de la secció 7, per recuperar l'estoc del Celler Roure Alt sense perdre les reserves que altres productors van rebre entre les 17:00 i les 17:25. (b) Quant WAL cal reproduir aproximadament, i de què depèn l'RTO real? (c) Escriu dues accions de postmortem que no siguin "més cura".

Exercici 3. La reconciliació nocturna cau a les 03:40 després de 95 minuts, amb el checkpoint {"productor_actual": "formatgeria-montblanc", "ultim_producte": "formatge-fresc", "processats": 1150}. Airflow la rellança a les 03:42. (a) Des d'on continua exactament i quins productes es reprocessen? (b) Un company proposa desar el checkpoint a Redis "perquè és més ràpid". Què es perd? (c) Un altre proposa que reconciliar_producte enviï un esdeveniment a inventari.alertes cada cop que corregeix estoc; quin problema apareix en reprendre, i com el resoldries amb el que s'ha vist a 02-05?

Solucions

Exercici 1.

(a) És una fallada grisa (model de temporització): el node és viu i respon a les comprovacions lleugeres (lease de Patroni, SELECT 1), però és inservible per a la feina real. Els detectors mesuren "respon", no "respon al que importa". (b) Readiness d'inventari: substituir SELECT 1 per una comprovació representativa amb el mateix timeout d'1 s (per exemple, UPDATE sonda SET ts = now() WHERE id = 1, una fila pròpia que exercita el WAL i el disc); si triga més d'1 s, 503 i les rèpliques d'inventari surten del balanceig, cosa que almenys deixa de contagiar comandes (el circuit de 07-04 farà la resta); risc: un pic puntual d'E/S treu totes les rèpliques alhora, així que cal for/llindar de fallades consecutives (3 seguides) abans de retirar-les. A Patroni: no hi ha detecció de latència de disc integrada, així que la via és una alerta sobre pg_stat_*/latència d'E/S de node_exporter (USE del disc, 07-01) amb un runbook el primer pas del qual és patronictl switchover --leader inv-bcn --candidate inv-vlc; o, si es vol automàtic, un watchdog propi que executi aquest switchover quan la latència d'escriptura superi N segons durant M minuts, amb el risc de commutar per una càrrega alta legítima; commutar és més barat que sobrevendre, així que llindars conservadors però automatitzats. (c) El switchover de Patroni fa un checkpoint, espera que la rèplica síncrona estigui al dia i degrada inv-bcn; les transaccions a mitges (no confirmades) s'avorten i el client rep un error, que comandes tradueix a UNAVAILABLE i reintenta contra el nou primari (07-04, sempre que la reserva sigui idempotent, 02-05); cap transacció confirmada no es perd perquè SYNCHRONOUS_MODE garantia que el commit ja era a inv-vlc abans de respondre a comandes.

Exercici 2.

(a) 1) Confirmar l'abast: a producció, SELECT count(*) FROM estoc WHERE productor='celler-roure-alt' AND unitats=0 i el log d'auditoria de l'operador (06-05) per fixar l'hora exacta (17:00:12). 2) En un node a part (inv-rest), restaurar la còpia base de les 02:00 i reproduir WAL amb recovery_target_time = '2026-09-11 17:00:00+02' (just abans de l'UPDATE), recovery_target_action = 'promote'. 3) Verificar a inv-rest: SELECT producte, unitats FROM estoc WHERE productor='celler-roure-alt'. 4) Calcular l'estoc correcte actual: el de les 17:00 a inv-rest menys les reserves confirmades entre les 17:00 i les 17:25 a producció (que sí que són vàlides: algunes van fallar per estoc 0, però les que van entrar abans de les 17:00:12 o d'altres productors no es toquen); a la pràctica, per al Celler Roure Alt: unitats_17:00 - reserves_confirmades_des_de_17:00(producte), obtingudes de km0_comandes/comandes.esdeveniments. 5) Aplicar a producció amb un UPDATE ... FROM (VALUES ...) dins d'una transacció, comparar abans del COMMIT, i registrar la correcció. 6) Rellançar la reconciliació de la secció 6 per a aquest productor com a verificació. Mai convertir inv-rest en primari: es perdrien 25 minuts de reserves de l'Horta La Vega i la Formatgeria Montblanc. (b) 15 hores de WAL (02:00 → 17:00); en un dia normal de km0_inventari poden ser desenes de GB durant la Verema; l'RTO real depèn de la velocitat de descàrrega des de MinIO i de replay (monofil a PostgreSQL), de si el runbook està provat (el DAG dominical dona la xifra: si diu 40 min, l'RTO d'1 h és creïble) i del temps de calcular i aplicar la correcció. (c) "Les connexions d'operadors a producció s'obren amb default_transaction_read_only = on i un role diferent que exigeix SET ROLE escriptor explícit amb auditoria"; "el prompt de psql i el nom del host de producció porten PROD i un color diferent, i els entorns de proves no comparteixen credencials de Vault amb producció"; "alerta de negoci reserves rebutjades per estoc amb for: 2m en lloc de 15, que ho hauria detectat a les 17:03".

Exercici 3.

(a) Comença a formatgeria-montblanc, amb productes_des_de(conn, "formatgeria-montblanc", "formatge-fresc"), és a dir, el primer producte amb un slug més gran que formatge-fresc en ordre alfabètic; l'Horta La Vega no es toca (ja completada). Com que el checkpoint només es confirma cada 500 productes o en acabar un productor, els productes processats entre l'últim checkpoint i la caiguda (fins a 499) es reprocessen; és correcte perquè reconciliar_producte és idempotent. (b) Es perd l'atomicitat entre checkpoint i correccions: Redis i PostgreSQL no comparteixen transacció, així que pot quedar el checkpoint avançat amb les correccions sense confirmar (se saltarien productes sense reconciliar) o a l'inrevés (només es reprocessa, cosa que és innòcua); a més, Redis sense persistència pot perdre el checkpoint en un reinici. La rapidesa és irrellevant: s'escriu una fila cada 500 productes. (c) En reprendre, els productes reprocessats que ja es van corregir a la transacció confirmada no es tornaran a corregir (actual ja és igual a esperat), però els del lot no confirmat sí que es corregeixen una altra vegada... i en realitat el problema és el contrari: si l'esdeveniment s'envia a Kafka directament des de reconciliar_producte, es publica abans del commit, i si el procés mor, hi ha un esdeveniment a inventari.alertes d'una correcció que mai no va passar; o, si es reintenta, un esdeveniment duplicat. La solució és el patró Outbox de 02-05: la correcció i la fila d'outbox s'escriuen a la mateixa transacció que el checkpoint, i el relay les publica després; els consumidors desdupliquen per clau (producte + execucio_id).

Conclusió

Detectar, tolerar, recuperar. La plataforma detecta amb heartbeats i health checks separats per intenció (liveness sense dependències, readiness amb elles i amb timeouts, startup per arrencar), i amb detectors adaptatius que expressen sospita en lloc de certesa, perquè en una xarxa no es pot distingir mort d'inabastable. Tolera amb redundància que només serveix si és independent per domini de fallada, i amb failover que només és segur si una majoria decideix i l'antic primari queda tancat: Patroni sobre etcd per a km0_inventari (lease, timeline, rèplica síncrona, switchover), ISR i min.insync.replicas per a Kafka, i quòrum sense líder per a Cassandra. Recupera estat amb checkpoints confirmats a la mateixa transacció que la feina, i dades amb còpies físiques i WAL arxivat a km0-backups que permeten tornar al minut anterior a l'error, snapshots de Cassandra i versionat de MinIO, tot plegat inútil si no es restaura de debò cada setmana. RPO i RTO posen números per component al que es promet, el pla de DR en pilot light cobreix la pèrdua d'una regió, i runbooks, on-call i postmortems sense culpa converteixen cada incident en un canvi del sistema i no en un retret.

Queda un forat entre la detecció i el failover: aquells 30 segons en què inv-bcn no respon i Patroni encara no ha promocionat inv-vlc, o els quatre segons de cada UPDATE a la fallada grisa de l'exercici 1. Durant aquest interval, comandes continua cridant inventari, cada crida ocupa un fil esperant, els fils s'esgoten, Kong comença a encuar, i un problema d'un disc es converteix en una caiguda de tota la plataforma. Tolerar fallades no és només substituir el que falla; és que qui crida el que falla no s'enfonsi amb ell. Això són els patrons de resiliència de la lliçó següent: timeouts amb pressupost, reintents que no empitjoren les coses, el circuit breaker que 01-04 va deixar pendent, bulkheads, degradació controlada i load shedding.

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