wandres.dev
PROBAR LA CONVERGENCIA · fuzzing y propiedades

El generador de historias concurrentes: un simulador mínimo

Modelar réplicas, operaciones, particiones y reordenaciones de entrega cabe en unas ochenta líneas si el tipo replicado entra como parámetro y la red se trata como un adversario explícito y no como un accidente.

⏱ 24 min

La propiedad de convergencia necesita un sujeto sobre el que hablar, y ese sujeto no es la función de fusión sino un sistema completo: varias réplicas con estado propio, una red que transporta mensajes con retrasos arbitrarios y un planificador que decide qué ocurre a continuación. Escribir ese simulador es el trabajo central del nivel y la parte que casi nadie hace, porque parece infraestructura y en realidad es especificación: el simulador es el documento donde queda escrito qué le concedes a la red y qué no. Si tu simulador no duplica mensajes, has decidido que tu transporte nunca los duplica, y esa decisión estará ahí aunque nadie la haya discutido. Lo que sigue es un simulador completo en unas ochenta líneas, con el tipo replicado como parámetro para que sirva igual a un conjunto, a un contador o a una secuencia, y con la red expresada como una función que puede negarse a entregar.

🎯 Al terminar esta lección sabrás
  • Reducir un CRDT basado en estado a cuatro funciones puras y hacer del simulador un código independiente del tipo.
  • Escribir el bucle de eventos que aplica operaciones locales, encola mensajes y entrega en orden arbitrario.
  • Modelar particiones como un predicado sobre pares de réplicas y una ventana temporal.
  • Definir con precisión el estado de reposo y entender por qué su implementación ingenua esconde los fallos.

El modelo mínimo: réplicas, red y planificador

Un simulador útil descansa sobre una abstracción estrecha del tipo replicado. Un CRDT basado en estado se describe por completo con cuatro funciones puras: el estado inicial, la aplicación de una operación local, la fusión de dos estados y la lectura del valor observable. Nada más. Si el simulador solo depende de esas cuatro, servirá para todos los tipos que el track ha construido sin modificarlo.

const orSet = {
  vacio: () => ({ vivos: new Map(), muertos: new Set() }),
  aplicar(s, op) {
    const vivos = new Map(s.vivos), muertos = new Set(s.muertos);
    if (op.clase === 'anadir') vivos.set(op.etiqueta, op.valor);
    else for (const [et, v] of vivos) if (v === op.valor) { muertos.add(et); vivos.delete(et); }
    return { vivos, muertos };
  },
  unir(a, b) {
    const muertos = new Set([...a.muertos, ...b.muertos]);
    const vivos = new Map([...a.vivos, ...b.vivos]);
    for (const et of muertos) vivos.delete(et);
    return { vivos, muertos };
  },
  observar: (s) => [...new Set(s.vivos.values())].sort(),
};

La red se modela como una lista de mensajes en vuelo. Cada mensaje lleva su emisor y una copia del estado de ese emisor en el instante del envío, y permanece en la lista hasta que alguien lo entrega. Esa lista es la fuente de todo el desorden: entregar el mensaje que está en la posición cero es una red que preserva el orden, entregar uno cualquiera es una red que reordena, y entregar sin sacarlo de la lista es una red que duplica. El planificador, por su parte, es la historia misma: en lugar de decidir sobre la marcha con azar, el generador produce una lista de eventos y el simulador la ejecuta paso a paso. Esa separación no es estética, es lo que permite que un contraejemplo sea un dato serializable, reproducible y reducible, en vez de una traza que solo existió una vez.

ℹ️
Por qué el simulador ejecuta un guion en lugar de sortear sobre la marcha

Si el simulador consulta un generador aleatorio dentro de su bucle, la ejecución deja de ser una función pura de un dato y pasa a depender del número de llamadas al azar, que cambia en cuanto tocas cualquier cosa. El reductor de la lección siguiente necesita justo lo contrario: quitar un evento del guion y volver a ejecutar debe producir exactamente la misma ejecución menos ese evento. Con azar interno, quitar un evento desplaza toda la secuencia de sorteos posteriores y el contraejemplo se desvanece al primer intento de reducirlo. Guion fuera, ejecución determinista dentro.

El simulador entero

Tres clases de evento bastan para cubrir todo lo que el nivel necesita. Una operación local, que muta la réplica y encola el estado resultante como mensaje pendiente. Una entrega, que toma un mensaje de la red y lo fusiona en la réplica destino. Y el reposo final, que no es un evento del guion sino una fase que el simulador ejecuta siempre al terminar.

function ejecutar(tipo, n, historia, alcanzable = () => true) {
  const replicas = Array.from({ length: n }, () => tipo.vacio());
  const red = [];
  for (const ev of historia) {
    const r = ev.replica % n;
    if (ev.clase === 'local') {
      replicas[r] = tipo.aplicar(replicas[r], ev.op);
      red.push({ de: r, estado: replicas[r] });
    } else if (ev.clase === 'entrega' && red.length > 0) {
      const i = ev.indice % red.length, msg = red[i], destino = ev.destino % n;
      if (!ev.duplicado) red.splice(i, 1);
      if (destino !== msg.de && alcanzable(msg.de, destino, ev.reloj)) {
        replicas[destino] = tipo.unir(replicas[destino], msg.estado);
      }
    }
  }
  // Reposo: cada replica ve todo lo pendiente, pero en su propio orden.
  const pendientes = red.map((m) => m.estado).concat(replicas.slice());
  for (let d = 0; d < n; d += 1) {
    for (let k = 0; k < pendientes.length; k += 1) {
      replicas[d] = tipo.unir(replicas[d], pendientes[(d + k) % pendientes.length]);
    }
  }
  return replicas;
}

Merece la pena señalar tres decisiones del código porque cada una responde a un error frecuente. El operador de módulo sobre ev.indice y sobre ev.destino convierte cualquier número en un evento válido, lo que evita tener que filtrar guiones mal formados y, sobre todo, hace que el reductor pueda simplificar un número sin romper la historia. La comparación destino !== msg.de impide que una réplica se fusione con su propio pasado, que no es un error pero gasta casos sin ejercitar nada. Y el mensaje lleva el estado completo del emisor, no la operación: eso es lo que hace del simulador un modelo de CRDT basado en estado, y adaptarlo a uno basado en operaciones exige encolar la operación y garantizar entrega causal, que es un cambio pequeño y una discusión larga.

Particiones, duplicados y reordenación

El parámetro alcanzable es donde vive el modelo de fallos, y su firma toma emisor, destino y un reloj lógico que no es más que la posición del evento en el guion. Con esa firma se expresan todas las patologías interesantes sin tocar el simulador.

flowchart TB
E[evento de entrega] --> A{alcanzable de a en t}
A -->|no| P[mensaje descartado, la particion lo bloquea]
A -->|si| D[fusion en la replica destino]
P --> R[queda en la red y se entregara en el reposo]
D --> M{duplicado}
M -->|si| K[el mensaje sigue en la red]
M -->|no| Q[el mensaje se retira de la red]
style P fill:#f38ba8,color:#11111b
style D fill:#a6e3a1,color:#11111b

Una partición se escribe como un predicado que aísla un subconjunto de réplicas durante una ventana del guion. La versión más simple separa una réplica del resto entre dos instantes, y basta para provocar la situación que más fallos revela: una réplica que sigue operando a ciegas y reaparece con un lote grande de cambios antiguos.

// La replica 0 queda aislada entre los instantes 10 y 25 del guion.
const parte = (de, a, t) => !(t > 10 && t < 25 && (de === 0) !== (a === 0));

El generador de historias completa el cuadro. Su trabajo es producir guiones cuya forma se parezca a la de una sesión real y cuyos parámetros estén ajustados para que la concurrencia sea alta, no para que sea realista en volumen.

function generarHistoria(r, n = 3, largo = 40) {
  const h = [];
  for (let k = 0; k < largo; k += 1) {
    const replica = Math.floor(r() * n);
    if (r() < 0.5) {
      const clase = r() < 0.7 ? 'anadir' : 'borrar';
      const op = { clase, valor: Math.floor(r() * 5), etiqueta: `${replica}-${k}` };
      h.push({ clase: 'local', replica, op });
    } else {
      h.push({ clase: 'entrega', replica, destino: Math.floor(r() * n),
        indice: Math.floor(r() * 8), duplicado: r() < 0.15, reloj: k });
    }
  }
  return h;
}
🎲

Cinco valores, no cinco mil

El rango estrecho de valor fuerza que dos réplicas operen sobre el mismo elemento a la vez, que es donde vive el conflicto. Un rango ancho produce historias sin interacción.

🧬

La etiqueta es única siempre

Combinar réplica e instante da un identificador irrepetible sin recurrir al azar, y hace que dos añadidos concurrentes del mismo valor sean elementos distintos, como exige el OR-Set.

📮

Índice acotado, no ilimitado

Sortear el índice sobre un rango pequeño y aplicarle el módulo concentra las entregas en los mensajes recientes, que es lo que hace una red real con reintentos.

👯

Duplicados con probabilidad baja

Un quince por ciento basta para ejercitar la idempotencia sin llenar la historia de eventos que no cambian nada.

El estado de reposo y la aserción final

La convergencia no afirma que las réplicas coincidan en todo momento, sino que coinciden cuando ya no queda nada por entregar. Definir ese momento es el punto donde el simulador puede engañarte, y el error es sutil y silencioso. La implementación ingenua del reposo recorre los mensajes pendientes y los fusiona en todas las réplicas en el mismo orden, y luego hace una ronda en la que cada réplica fusiona el estado de todas las demás, también en el mismo orden. El resultado es que todas ejecutan exactamente la misma secuencia de fusiones, de modo que terminan iguales aunque la fusión no sea conmutativa: el reposo mal implementado demuestra convergencia por construcción y anula la prueba entera. La versión de arriba evita la trampa rotando el orden en cada réplica, de forma que dos réplicas nunca ven la misma secuencia.

Reposo ingenuo   todas fusionan en el mismo orden
                 -> converge siempre, incluso con fusion no conmutativa
Reposo rotado    cada replica fusiona en un orden distinto
                 -> solo converge si la fusion es de verdad conmutativa

Con el reposo bien definido, la aserción es una línea: se lee el valor observable de cada réplica y se comprueba que todos son iguales entre sí, sin comparar contra ningún resultado esperado, porque no lo hay.

function convergen(tipo, n, historia, alcanzable) {
  const fin = ejecutar(tipo, n, historia, alcanzable);
  const ref = JSON.stringify(tipo.observar(fin[0]));
  return fin.every((e) => JSON.stringify(tipo.observar(e)) === ref);
}
El simulador es la especificación del modelo de fallos, y por eso vale más que la prueba que lo usa

Hay una inversión de valor en este código que conviene ver con claridad, porque decide dónde poner el esfuerzo durante los próximos años del proyecto. La intuición dice que lo valioso es la propiedad y que el simulador es andamiaje. Es exactamente al revés. La propiedad de convergencia es una línea que no cambia nunca y que además está publicada en la literatura desde hace veinte años; el simulador, en cambio, es el único artefacto de tu repositorio donde queda escrito qué crees que puede hacerle la red a tu sistema. Cada omisión suya es una hipótesis tácita ascendida a hecho: si no hay duplicados, has afirmado que tu transporte no los produce; si no hay particiones asimétricas, has afirmado que cuando dos nodos se separan lo hacen en los dos sentidos a la vez, cosa que las redes reales incumplen constantemente; si el reposo entrega todo, has afirmado que ningún mensaje se pierde para siempre. Ninguna de esas afirmaciones aparecerá jamás en un documento de diseño, y las tres gobiernan la corrección del producto. De ahí se siguen dos prácticas que cuestan poco y cambian mucho. La primera es tratar el archivo del simulador como un documento de arquitectura y no como código de pruebas: se revisa en pareja, se comenta cada patología con la razón por la que se incluye o se excluye, y cuando alguien cambia el transporte lo primero que se pregunta es si el simulador sigue siendo una cota superior del comportamiento nuevo. La segunda, más rentable todavía: cada vez que aparezca en producción una anomalía de sincronización, la reparación no empieza por el algoritmo sino por el simulador, añadiendo la patología que la produjo hasta que la propiedad falle. Solo entonces se arregla el código. Un equipo que trabaja así acumula, incidente tras incidente, un modelo de fallos cada vez más fiel a su red concreta, y ese modelo es un activo que sobrevive a los cambios de biblioteca, de tipo de dato y de personas, porque describe el mundo y no la implementación.

⚔️ Construye el simulador y hazle daño
  1. Implementa las cuatro funciones puras para tu tipo replicado y comprueba que el simulador funciona sin conocer nada de él.
  2. Ejecuta trescientas historias generadas y verifica que ninguna diverge, imprimiendo cuántas tuvieron concurrencia real sobre el mismo valor.
  3. Sustituye el reposo rotado por el ingenuo y comprueba que un registro de última escritura sin desempate por réplica pasa la prueba.
  4. Vuelve al reposo rotado, confirma que ese mismo registro ahora falla y guarda la semilla del primer fallo.
  5. Añade una partición asimétrica en la que la réplica cero recibe pero no envía, y extiende luego el simulador con pérdida definitiva de mensajes: decide por escrito si tu propiedad de convergencia sigue siendo válida bajo ese modelo.