La prueba de convergencia: dos réplicas y una consola
Dos réplicas editan a la vez sobre una base común, intercambian sus operaciones en órdenes distintos y terminan con el mismo texto y la misma secuencia interna, comprobado sobre las 5040 permutaciones posibles.
Las tres lecciones anteriores han dejado sobre la mesa unas ciento veinte líneas de JavaScript. Ahora toca la única pregunta que importa: ¿converge? Y converger no significa que las dos réplicas muestren textos parecidos, ni que el resultado sea el que un humano habría esperado. Significa algo mucho más estricto y mucho más comprobable: que después de que ambas hayan visto exactamente el mismo conjunto de operaciones, sus arrays internos contengan los mismos nodos en el mismo orden, con independencia de la secuencia en que llegaron los mensajes. Esta lección monta el escenario, lo ejecuta, enseña la salida literal de la consola y después la somete a la única prueba que despeja las dudas: entregar las operaciones en todos los órdenes posibles, uno por uno, y verificar que el resultado no se mueve ni una vez.
- Montar un escenario con base común, edición concurrente e intercambio desordenado.
- Leer la salida real y entender por qué el texto final no es el de ninguna de las dos.
- Comprobar la convergencia a nivel de secuencia interna y no solo de texto visible.
- Ejecutar la prueba exhaustiva sobre todas las permutaciones de entrega.
El escenario: una base común y dos ediciones a la vez
El montaje tiene tres fases. En la primera, Ana escribe HOLA y difunde sus cuatro operaciones, de modo que las dos réplicas parten de un estado idéntico. En la segunda, cada una edita sin ver a la otra: Ana añade MUNDO al final y Beto añade CRUEL al final y además borra la H inicial. En la tercera intercambian todo, y aquí está lo importante: Ana recibe las operaciones de Beto en orden inverso al que se emitieron, para que el resultado no pueda depender del orden de llegada por casualidad.
const ana = crearReplica("ana");
const beto = crearReplica("beto");
// Fase 0: ana escribe y sincroniza. Las dos replicas parten del mismo estado.
const base = escribir(ana, 0, "HOLA");
for (const op of base) recibir(beto, op);
console.log("base comun ana=%j beto=%j", texto(ana), texto(beto));
// Fase 1: edicion concurrente. Ninguna ve todavia lo que hace la otra.
const deAna = escribir(ana, 4, " MUNDO");
const deBeto = escribir(beto, 4, " CRUEL");
deBeto.push(borrar(beto, idVisible(beto, 0)));
console.log("antes de sincronizar ana=%j beto=%j", texto(ana), texto(beto));
// Fase 2: intercambio en ordenes distintos
for (const op of [...deBeto].reverse()) recibir(ana, op); // inverso
for (const op of deAna) recibir(beto, op); // directo
console.log("despues de sincronizar ana=%j beto=%j", texto(ana), texto(beto));
console.log("mismo texto: %s misma secuencia interna: %s pendientes: %d/%d",
texto(ana) === texto(beto),
ana.lista.map((n) => clave(n.id)).join() === beto.lista.map((n) => clave(n.id)).join(),
ana.pendientes.length, beto.pendientes.length);
base comun ana="HOLA" beto="HOLA"
antes de sincronizar ana="HOLA MUNDO" beto="OLA CRUEL"
despues de sincronizar ana="OLA CRUEL MUNDO" beto="OLA CRUEL MUNDO"
mismo texto: true misma secuencia interna: true pendientes: 0/0
Leer la salida: nadie ve lo que escribió
La segunda línea es la interesante desde el punto de vista humano. Antes de sincronizar, Ana lee HOLA MUNDO y Beto lee OLA CRUEL. Después de sincronizar, ninguno de los dos ve lo que tenía: los dos leen OLA CRUEL MUNDO. El texto de Ana ha perdido su H porque el borrado de Beto era una operación legítima que ella no había visto, y el bloque de Beto se ha colado por delante del suyo porque el desempate por sello lo puso primero.
Conviene desmenuzar por qué el resultado es exactamente ese y no otro. Los dos espacios que abren cada ráfaga llevan el contador cinco, porque ambas réplicas habían visto los mismos cuatro caracteres; el empate lo rompe el nombre, y beto es posterior a ana en orden lexicográfico, así que su subárbol se coloca primero. Dentro de cada ráfaga no hay competencia alguna, porque cada carácter cuelga del anterior de su propia tanda, y por eso los seis caracteres de cada bloque salen juntos y en orden. El borrado, por su parte, no participa en la ordenación: solo apaga un nodo que sigue ahí.
Esta salida es una demostración perfecta de convergencia y una demostración perfecta de que la convergencia, por sí sola, no basta. Nadie escribió OLA CRUEL MUNDO y nadie lo esperaba. El sistema es correcto según su definición —mismo conjunto de operaciones, mismo estado— y aun así ha producido una frase que ningún autor tenía en la cabeza. Esa brecha entre lo demostrable y lo deseable es justo el tema del nivel 33, y la razón de que exista una literatura entera sobre entrelazado y sobre variantes con garantías más fuertes. Vale la pena que la veas aquí, en tu propia consola, antes de convencerte de que la convergencia resuelve el problema del texto colaborativo.
Más allá del texto: la misma secuencia interna
La comprobación de la última línea es más fuerte que comparar los textos y merece una explicación, porque la diferencia no es cosmética. Dos réplicas podrían mostrar el mismo texto y tener estructuras internas distintas —por ejemplo, con las lápidas colocadas en sitios diferentes—, y esa divergencia oculta se manifestaría en la siguiente edición: una inserción hecha en la posición tres acabaría anclada a nodos distintos en cada réplica y a partir de ahí ya no habría vuelta atrás.
const huellaAna = ana.lista.map((n) => `${clave(n.id)}:${n.borrado ? 0 : 1}`).join("|");
const huellaBeto = beto.lista.map((n) => `${clave(n.id)}:${n.borrado ? 0 : 1}`).join("|");
console.log("huellas identicas:", huellaAna === huellaBeto);
flowchart TD A[fase cero: base comun HOLA] --> B[ana escribe espacio MUNDO] A --> C[beto escribe espacio CRUEL y borra la H] B --> D[ana recibe lo de beto en orden inverso] C --> E[beto recibe lo de ana en orden directo] D --> F[OLA CRUEL MUNDO] E --> F F --> G[misma huella interna y cola vacia] style B fill:#89b4fa,color:#11111b style C fill:#a6e3a1,color:#11111b style F fill:#f9e2af,color:#11111b style G fill:#cba6f7,color:#11111b
Mismo texto visible
Necesario y muy insuficiente. Dos estructuras distintas pueden proyectar la misma cadena y divergir en la siguiente edición.
Misma huella interna
Mismos sellos, en el mismo orden, con las mismas marcas. Es la igualdad que de verdad garantiza el futuro.
Colas vacías
Certifica que nada quedó esperando un ancla. Sin esta comprobación, la igualdad podría ser una coincidencia.
Órdenes distintos
Si las dos réplicas reciben en el mismo orden, la prueba no prueba nada. La asimetría de la entrega es el experimento.
Y las colas de pendientes vacías son la tercera parte del certificado. Si una operación se hubiera quedado atascada esperando un ancla, el texto podría coincidir por casualidad —al fin y al cabo, faltarían caracteres en un sitio que quizá no se nota— mientras la estructura ya habría divergido. Comprobar pendientes.length === 0 en las dos réplicas es lo que convierte parece igual en ha visto todo y ha integrado todo.
Todas las permutaciones, no una muestra
Un único orden de entrega no prueba nada; probaría, como mucho, que ese orden concreto funciona. La prueba honesta es exhaustiva: entregar a Ana las siete operaciones de Beto en cada una de las cinco mil cuarenta permutaciones posibles, y a Beto las seis de Ana en cada una de las setecientas veinte, y comprobar que el conjunto de resultados distintos tiene exactamente un elemento.
function* permutaciones(a) {
if (a.length <= 1) { yield a; return; }
for (let i = 0; i < a.length; i++)
for (const resto of permutaciones([...a.slice(0, i), ...a.slice(i + 1)]))
yield [a[i], ...resto];
}
const finales = new Set();
for (const orden of permutaciones([0, 1, 2, 3, 4, 5, 6])) {
const { ana, deBeto } = escena(); // escenario recien montado
for (const i of orden) recibir(ana, deBeto[i]);
finales.add(texto(ana) + "|" + ana.pendientes.length);
}
console.log("resultados distintos:", [...finales]);
permutaciones de las 7 operaciones de beto entregadas a ana: 5040
resultados distintos: "OLA CRUEL MUNDO|0"
permutaciones de las 6 operaciones de ana entregadas a beto: 720
resultados distintos: "OLA CRUEL MUNDO|0"
Cinco mil setecientos sesenta órdenes de entrega, un solo resultado y ninguna operación atascada. Fíjate en que la prueba incluye los órdenes que violan la causalidad de la forma más brutal posible: el borrado entregado antes que la inserción del nodo que borra, el último carácter de una ráfaga entregado antes que el primero, y todas las combinaciones intermedias. La cola de pendientes las absorbe y el bucle de reintento las resuelve en cuanto llega la pieza que faltaba.
Conviene notar qué parte del sistema está soportando esa hostilidad, porque no es el algoritmo de ordenación. La regla de desempate no sabe nada del orden de llegada y nunca lo ha sabido: recibe un nodo y un array y coloca. Lo que absorbe el desorden es la pareja formada por la comprobación del padre y la cola de reintento, y esa pareja es diminuta —cinco líneas entre las dos— comparada con la contabilidad de secuencias que necesitaría un sistema que dependiera del orden. Es un buen sitio para medir la diferencia de esfuerzo entre las dos filosofías: aquí, tolerar cualquier permutación de entrega no costó un protocolo, costó una cola.
Y hay un caso límite que la prueba también cubre sin que se note: la entrega repetida. Si en lugar de permutar entregas dos veces la misma operación, la comprobación de duplicado de aplicar devuelve verdadero sin tocar nada y el resultado es idéntico. Esa propiedad es la que permite que la capa de red sea deliberadamente tonta —reenviar todo lo que no se confirmó, reenviar el lote entero al reconectar, no llevar registro de qué vio cada par— sin poner en riesgo la corrección. Merece la pena añadirla explícitamente a tu conjunto de pruebas: barajar y además duplicar al azar una de cada tres operaciones.
El generador de permutaciones deja de ser viable por encima de ocho o nueve operaciones, pero la idea se conserva con órdenes aleatorios: monta el escenario, baraja con una semilla registrada, entrega, compara huellas, repite unos miles de veces y guarda la semilla de cualquier fallo para poder reproducirlo. Es la forma más barata que existe de detectar una regresión en la regla de desempate o en la sincronización del reloj, y detecta clases enteras de errores que ningún test de ejemplos concretos alcanza. En una implementación real, esta prueba con tres réplicas y ediciones generadas al azar debería ser lo primero que se escribe, antes incluso de la interfaz.
Merece la pena entender qué se ha comprobado exactamente, porque la conclusión es mucho más fuerte que el editor no tiene bugs y explica por qué esta familia de estructuras cambió lo que se podía construir. Un sistema colaborativo convencional es correcto respecto de una trayectoria: el estado final es el resultado de aplicar una secuencia concreta de cambios en un orden concreto, y por eso hay que preservar ese orden con números de secuencia, con confirmaciones, con reintentos ordenados y con un servidor que decida quién va antes que quién. Cualquier fallo del transporte —un mensaje duplicado, uno adelantado, uno que llega tres veces— es un fallo de corrección, y por eso la mitad del código de esos sistemas es contabilidad de mensajes. Lo que las cinco mil setecientas sesenta ejecuciones acaban de demostrar es que aquí la corrección no depende de la trayectoria en absoluto: el estado final es una función del conjunto de operaciones recibidas, y un conjunto no tiene orden, no tiene duplicados y no tiene principio ni fin. La consecuencia práctica es un cambio de qué se puede exigir a la red. Un transporte que puede reordenar, duplicar, retrasar arbitrariamente y reconectar reenviando de más deja de ser un transporte defectuoso que hay que corregir y pasa a ser un transporte perfectamente adecuado, porque las únicas propiedades que el algoritmo necesita son que todo llegue alguna vez y que la operación que crea un nodo llegue antes que las que lo citan; y esa segunda ni siquiera se exige al transporte, se resuelve localmente con la cola de pendientes que has visto absorber los órdenes más hostiles. De ahí sale, en una línea, la razón de que este diseño y no otro sea el que sostiene lo local-first: cuando la corrección depende del conjunto y no de la secuencia, la desconexión deja de ser un estado excepcional que hay que gestionar y pasa a ser el caso normal. Una réplica que estuvo un año apagada no necesita ninguna fase de reconciliación, ningún diálogo de conflictos y ningún permiso de un servidor: le basta con recibir lo que le falta, en cualquier orden, y aplicarlo. La prueba que acabas de ejecutar es, mirada así, la demostración operativa de que el servidor central dejó de ser necesario para la corrección, y todo lo demás del track —la sincronización, la compactación, la autoridad— es ingeniería sobre esa base.
- Reproduce el escenario completo y confirma que obtienes exactamente
OLA CRUEL MUNDOen las dos réplicas. - Ejecuta la prueba exhaustiva de las 5040 permutaciones y verifica que el conjunto de resultados tiene un solo elemento.
- Sustituye la comparación de textos por la comparación de huellas internas y comprueba que también es única.
- Añade una tercera réplica que reciba toda la historia barajada y verifica que llega al mismo estado que las otras dos.
- Rompe la sincronización del reloj en el receptor, repite la prueba y anota cuántas permutaciones fallan.
- Genera ediciones aleatorias con tres réplicas hasta veinte mil caracteres y comprueba que las tres huellas coinciden.