wandres.dev
CONSTRUIR UN OR-SET · desde cero

El escenario que lo justifica: dos réplicas, seis pasos, una convergencia

Una réplica añade y se arrepiente mientras la otra añade lo mismo sin saberlo: la traza completa de cada estado intermedio muestra dónde estaba el dato que las estructuras ingenuas destruían.

⏱ 19 min

Toda la maquinaria de las tres lecciones anteriores existe por un escenario, y es tan corriente que resulta casi decepcionante enunciarlo: alguien añade algo a una lista compartida, se arrepiente y lo quita; otra persona, en otro dispositivo y sin conexión, añade exactamente lo mismo. Nada más. No hay carreras de milisegundos, ni relojes desviados, ni particiones bizantinas, ni tres nodos discutiendo un valor: hay dos personas y una lista de la compra. Y sin embargo ese guion de seis pasos es el que rompe las dos estructuras ingenuas, el que decide la forma de la etiqueta, el que obliga a que la baja arrastre su contexto y el que justifica cada línea de la clase. Esta lección lo ejecuta imprimiendo el estado completo de las dos réplicas después de cada paso, para que se pueda señalar con el dedo el instante exacto en el que un diseño peor habría perdido el dato y ver dónde estaba guardada la información que lo salva.

🎯 Al terminar esta lección sabrás
  • Seguir la traza completa del escenario y saber decir en cada paso qué sabe cada réplica y qué ignora.
  • Localizar el instante concreto en el que las estructuras ingenuas destruyen el trabajo concurrente.
  • Entender por qué el resultado final es una decisión de diseño y no un empate resuelto por azar.
  • Comprobar que la segunda retirada sí funciona y por qué eso hace reparable la política elegida.

El guion y su instrumentación

El guion tiene dos réplicas que empiezan sincronizadas y vacías. La réplica A añade leche, cambia de idea y la retira. La réplica B, que durante todo ese rato no ha recibido ni un byte, añade leche también. Después se sincronizan. La instrumentación imprime, tras cada paso, los valores presentes de cada réplica, las etiquetas vivas del elemento en disputa y el conjunto completo de bajas conocidas, que son los tres datos necesarios para explicar cualquier resultado.

const { ORSet, serializar } = require("./orset.js");

const A = new ORSet("A");
const B = new ORSet("B");
let paso = 0;
function traza(que) {
  paso += 1;
  const f = (s) => `[${s.values().join(",") || "vacio"}] ` +
    `vivas=${JSON.stringify(s.vivas("leche"))} bajas=${JSON.stringify([...s.bajas])}`;
  console.log(`${paso}. ${que}`);
  console.log(`   A: ${f(A)}`);
  console.log(`   B: ${f(B)}`);
}

traza("estado inicial, las dos replicas vacias y sincronizadas");
const t1 = A.add("leche");
traza(`A anade leche con etiqueta ${t1}`);
const muertas = A.remove("leche");
traza(`A retira leche y mata exactamente ${JSON.stringify(muertas)}`);
const t2 = B.add("leche");
traza(`B, sin haber recibido nada, anade leche con etiqueta ${t2}`);
flowchart TD
I[estado inicial compartido y vacio] --> A1[A anade leche etiqueta A uno]
A1 --> A2[A retira leche y mata A uno]
I --> B1[B anade leche etiqueta B uno]
A2 --> M[intercambio de estado]
B1 --> M
M --> F[leche presente por la etiqueta B uno]
style A2 fill:#f9e2af,color:#11111b
style B1 fill:#89b4fa,color:#11111b
style F fill:#a6e3a1,color:#11111b

Conviene fijarse en la forma del diagrama antes de mirar la salida. Las dos ramas parten del mismo estado inicial y no se tocan hasta la mezcla: no hay ninguna flecha de A a B ni de B a A durante los pasos intermedios, y esa ausencia es la definición operativa de concurrencia. B no está retrasada respecto de A, ni A adelantada respecto de B; sencillamente ninguna de las dos historias precede a la otra, y cualquier resolución que trate a una como posterior estará inventando un orden que no existió.

La traza, paso a paso

1. estado inicial, las dos replicas vacias y sincronizadas
   A: [vacio] vivas=[] bajas=[]
   B: [vacio] vivas=[] bajas=[]
2. A anade leche con etiqueta A:1
   A: [leche] vivas=["A:1"] bajas=[]
   B: [vacio] vivas=[] bajas=[]
3. A retira leche y mata exactamente ["A:1"]
   A: [vacio] vivas=[] bajas=["A:1"]
   B: [vacio] vivas=[] bajas=[]
4. B, sin haber recibido nada, anade leche con etiqueta B:1
   A: [vacio] vivas=[] bajas=["A:1"]
   B: [leche] vivas=["B:1"] bajas=[]

El paso 3 es el momento decisivo de toda la lección y merece una lectura milimétrica. La retirada de A no dice leche está muerta; dice la etiqueta A:1 está muerta. Es una afirmación mucho más modesta, y esa modestia es lo que la hace compatible con un futuro que A desconoce por completo. En el instante de ejecutarse, A no sabe que existe una réplica llamada B, no sabe qué está haciendo y no sabe si esa acción tiene algo que ver con leche; lo único que sabe es qué tenía delante, y es exactamente sobre eso sobre lo que se pronuncia.

El paso 4 muestra el otro lado. B emite B:1 sin consultar nada y sin poder consultar nada, porque no hay red. Esa etiqueta no puede coincidir con ninguna de A porque lleva dentro el identificador de réplica, y no puede estar retirada por nadie porque acaba de nacer. Las dos réplicas están en estados incompatibles a la vista —una cree que leche no está, la otra que sí— y sin embargo cada una guarda información que la otra no tiene y que no se contradice: A guarda una muerte, B guarda un nacimiento, y una muerte y un nacimiento distintos conviven sin problema.

const fusionA = ORSet.merge(A, B);
const fusionB = ORSet.merge(B, A);
console.log("5. las dos replicas intercambian estado");
console.log("   A tras mezclar:", fusionA.values(), "vivas:", fusionA.vivas("leche"));
console.log("   B tras mezclar:", fusionB.values(), "vivas:", fusionB.vivas("leche"));
console.log("   convergen:", serializar(fusionA) === serializar(fusionB));
console.log("   estado comun:", serializar(fusionA));
5. las dos replicas intercambian estado
   A tras mezclar: [ 'leche' ] vivas: [ 'B:1' ]
   B tras mezclar: [ 'leche' ] vivas: [ 'B:1' ]
   convergen: true
   estado comun: {"altas":{"leche":["A:1","B:1"]},"bajas":["A:1"]}

El estado común contiene las dos altas y una sola baja. A:1 está en el mapa de altas y también entre las bajas, así que no cuenta; B:1 está en altas y no está entre las bajas, así que cuenta; el elemento existe. No hubo comparación de relojes, no hubo desempate por identificador de réplica, no hubo regla de última escritura y no hubo azar: el resultado sale de mirar dos conjuntos y preguntar si la diferencia es vacía.

ℹ️
Dónde estaba el dato que las estructuras ingenuas destruían

Compara este estado con el de la primera lección. La estructura ingenua guardaba muertos: ['leche'], y esa marca era una afirmación sobre el valor, aplicable a cualquier alta pasada o futura que usara ese valor. Aquí la marca es bajas: ['A:1'], una afirmación sobre un acto concreto. La información que salva el dato de B no es una información añadida: es la misma información expresada con menos ambición. El diseño correcto no guarda más que el ingenuo, guarda algo más pequeño, y por eso también es más barato en el caso general.

Por qué esto no es un empate resuelto por azar

Es tentador leer el resultado como que gana el alta porque llegó después, y es falso. La estructura no tiene forma alguna de saber que la operación de B ocurrió después de la de A en tiempo de reloj de pared, y de hecho el resultado sería idéntico si B hubiera añadido leche una hora antes: lo que decide no es el instante sino la ausencia de relación causal, y una etiqueta que nadie ha observado sobrevive con independencia de cuándo se emitió. Basta reordenar los pasos 2 a 4 y volver a ejecutar para confirmar que el estado final no cambia, cosa que la enumeración de órdenes de la lección anterior ya demostró en general.

Tampoco es un empate. Un empate supone dos afirmaciones contradictorias sobre la misma cosa, y aquí no las hay: A afirmó que A:1 está muerta, cosa que sigue siendo cierta en el estado final, y B afirmó que B:1 existe, cosa que también sigue siendo cierta. Las dos afirmaciones se conservan íntegras y el resultado es la consecuencia lógica de ambas, no la victoria de una. Que el usuario vea leche presente es una interpretación de esos hechos hecha por la función de lectura, y esa función es lo único que se podría cambiar si el dominio pidiera lo contrario, sin tocar ni el almacén ni la mezcla ni las tres propiedades.

⏱️

No decidió el reloj

El resultado sería idéntico si B hubiera escrito una hora antes. Lo que cuenta no es el instante sino que ninguna etiqueta observó a la otra.

🆔

No decidió el identificador

No hubo desempate por nombre de réplica ni por orden alfabético. Las dos aportaciones se conservaron y ninguna arbitró sobre la otra.

📚

Decidieron los hechos guardados

Una etiqueta muerta y otra viva conviven sin contradicción, y la presencia sale de restar dos conjuntos, no de elegir un ganador.

👁️

Y decidió la lectura

Mostrar el elemento es una interpretación de esos hechos. Cambiarla es cambiar diez líneas, y los datos históricos se reinterpretan solos.

Esa separación entre lo que se guarda y lo que se muestra tiene una consecuencia práctica que conviene comprobar de inmediato en cualquier implementación propia: si al cambiar la política de lectura hace falta migrar datos, es que la política se había colado dentro del almacén y la estructura no es tan reversible como parecía.

La concurrencia no es un conflicto: es la ausencia de una pregunta que nadie hizo

Merece la pena detenerse en lo que este escenario revela sobre la palabra conflicto, porque el vocabulario heredado de las bases de datos centralizadas induce un error de razonamiento que sobrevive mucho después de haber entendido la técnica. En un sistema con árbitro, dos escrituras sobre el mismo dato compiten por un recurso escaso —la única posición de memoria autorizada— y por eso hablar de conflicto es literal: hay una sola casilla y dos candidatos, así que uno tiene que perder. Cuando se retira el árbitro, lo que desaparece no es el árbitro sino la casilla. Ya no hay un lugar único donde leche esté o no esté; hay dos actos independientes, uno de A y otro de B, y ninguno ocupa el sitio del otro porque no existe un sitio. La sensación de conflicto no la produce entonces la concurrencia, la produce el intento de proyectar dos historias sobre una estructura que solo tiene espacio para una, y ese aplastamiento es una decisión de la función de lectura, no un hecho de los datos. De ahí se sigue la reformulación que cambia cómo se diseña todo lo demás: el trabajo no consiste en resolver conflictos sino en decidir qué proyección se muestra al usuario, y esa decisión es reversible mientras la información subyacente esté intacta. Fíjate en la diferencia de consecuencias. Un sistema que resuelve conflictos toma una decisión destructiva en el peor momento posible —durante la fusión, sin usuario delante, sin contexto y sin posibilidad de revisión— y a partir de ahí ya no se puede hacer nada, porque el dato descartado no existe en ninguna parte. Un sistema que conserva los actos y proyecta al leer toma una decisión no destructiva que puede cambiar de opinión: si mañana descubres que tu dominio necesitaba prioridad a la baja en lugar de al alta, cambias diez líneas de la función de lectura y todos los datos históricos se reinterpretan solos, correctamente, sin migración y sin pérdida, porque la información que la nueva política necesita nunca se tiró. Esa reversibilidad es, con diferencia, la propiedad más valiosa de esta familia de estructuras y la que menos se menciona al presentarlas; se habla siempre de que convergen, que es lo espectacular, y casi nunca de que separan el registro de los hechos de su interpretación, que es lo profundo. Es la misma separación que distingue un libro de asientos de un saldo, un registro de eventos de una vista materializada, una grabación de una transcripción. Y trae consigo el mismo precio, que ya se ha nombrado y volverá a nombrarse: quien conserva los actos conserva historia, y la historia no se sobrescribe, se acumula. Toda la lección siguiente, y buena parte del resto del track, es el intento de pagar ese precio sin renunciar a lo que compra.

La segunda vuelta, que es la que hace justa la política

Queda el final del guion, y es el que convierte una política discutible en una defendible. A mira su lista, ve leche reaparecida, se molesta razonablemente y la retira otra vez. Esta segunda retirada se ejecuta sobre un estado que ya conoce B:1.

const segunda = ORSet.merge(fusionA, new ORSet("A"));
const muertas2 = segunda.remove("leche");
console.log("6. A vuelve a retirar y ahora mata", JSON.stringify(muertas2), "->", segunda.values());
6. A vuelve a retirar y ahora mata ["B:1"] -> []

La retirada funciona a la primera porque ahora sí observó la etiqueta que antes no podía observar. Esa es la propiedad que sostiene toda la elección de política y conviene enunciarla con precisión: el error que comete esta estructura es visible y reparable con el mismo gesto que lo produjo. El usuario ve el elemento reaparecido, repite la acción que ya conocía y esta vez es definitiva —salvo que alguien vuelva a añadirlo desde una tercera réplica desconectada, en cuyo caso volverá a verlo y volverá a poder quitarlo—. La alternativa, que es que el trabajo de B desaparezca en silencio, produce un error invisible: nadie lo ve, no hay nada que repetir y el dato no está en ninguna parte.

📝
Reparable no significa que la interfaz pueda ignorarlo

Que el error sea corregible con un gesto no autoriza a que el gesto llegue sin explicación. Un elemento que reaparece sin ninguna señal parece un fallo del programa, y esa impresión, una vez formada, no se deshace. La misma reaparición mostrada como una nota discreta que diga quién lo añadió y cuándo deja de ser un fallo y pasa a ser información. El algoritmo es idéntico en los dos casos; lo único que cambia es si el producto reconoce lo que acaba de pasar.

⚔️ Ejecuta el guion y sus variantes
  1. Reproduce la traza completa y comprueba que obtienes las mismas etiquetas y el mismo estado común.
  2. Reordena los pasos para que B añada antes que A y verifica que el estado final no cambia en absoluto.
  3. Añade una tercera réplica que también añada leche sin conexión y traza cuántas etiquetas hacen falta ahora para que el elemento desaparezca.
  4. Cambia únicamente la función de lectura para dar prioridad a la baja, vuelve a ejecutar el guion sin tocar nada más y comprueba qué sale.
  5. Implementa la nota de interfaz que explica la reaparición, tomando de la traza el dato de qué réplica emitió la etiqueta superviviente.