wandres.dev
CONSTRUIR UNA SECUENCIA · RGA paso a paso

Insertar: generar el sello, buscar el sitio y desempatar

La inserción de RGA son diez líneas: un reloj de Lamport que fabrica el sello, un barrido a la derecha del padre mientras se encuentren sellos mayores, y una regla de desempate que basta para que todas las réplicas coincidan.

⏱ 24 min

Con el nodo definido, insertar consiste en responder tres preguntas seguidas: qué sello le doy, en qué punto del array lo coloco y qué hago cuando otra réplica ya puso algo justo ahí. Las tres respuestas caben en un puñado de líneas, y la tercera es la que da a RGA su reputación de algoritmo asombrosamente barato: el desempate entre hermanos concurrentes se resuelve comparando dos números, sin negociar nada con nadie y sin recordar nada de lo que pasó antes. Lo interesante es que el barrido que encuentra el sitio y el desempate entre hermanos son la misma línea de código, y que esa coincidencia no es un atajo de implementación sino una consecuencia directa del invariante que cerraba la lección anterior. Esta lección escribe esas líneas, las ejecuta y enseña qué sellos concretos genera cada réplica cuando dos personas teclean a la vez en el mismo punto.

🎯 Al terminar esta lección sabrás
  • Generar sellos con un reloj de Lamport y saber cuándo se incrementa y cuándo se sincroniza.
  • Implementar la integración como un barrido a la derecha del padre.
  • Justificar por qué el barrido salta subárboles enteros sin comprobarlo explícitamente.
  • Ejecutar el caso de dos ráfagas concurrentes y leer los sellos reales que produce.

Generar el sello: un reloj que solo sabe avanzar

El sello se fabrica con un contador local que sigue dos reglas y nada más. Al emitir una operación propia, el contador sube en uno. Al aceptar una operación ajena, el contador salta al máximo entre su valor actual y el contador que traía la operación recibida. Es el reloj de Lamport en su forma más desnuda, y garantiza justo lo que necesitamos: si una réplica vio un nodo antes de escribir, su sello nuevo será mayor que el de aquel nodo.

function insertar(rep, padre, valor) {
  rep.reloj += 1;                                // regla uno: emitir sube el reloj
  const op = {
    tipo: "insertar",
    id: { contador: rep.reloj, replica: rep.replica },
    padre,
    valor
  };
  aplicar(rep, op);
  return op;                                     // se devuelve para difundirla
}

La segunda regla vive en el receptor y es igual de corta: rep.reloj = Math.max(rep.reloj, op.id.contador). Sin ella el reloj de una réplica silenciosa se quedaría atrás y sus escrituras posteriores nacerían con sellos artificialmente pequeños, lo que rompería el invariante y colocaría los nodos en sitios absurdos. Con ella, el reloj de cada réplica es siempre al menos tan grande como todo lo que ha visto.

⚠️
El reloj no mide tiempo y no debe interpretarse como tal

Un contador de Lamport ordena, pero no fecha. Que el sello de Beto sea mayor que el de Ana no significa que Beto escribiera después en el reloj de pared; significa, o bien que Beto había visto lo de Ana, o bien que fueron concurrentes y el contador coincidió por casualidad de aritmética. La distinción importa porque tarde o temprano alguien querrá mostrar en la interfaz quién escribió antes, y ese dato no está en la estructura. Si lo necesitas, guarda una marca de tiempo aparte, como un campo más del nodo, y acepta que sirve para presentación y no para ordenar la secuencia.

Encontrar el sitio: un barrido a la derecha del padre

Ya con el sello, colocar el nodo en el array linealizado es un bucle. Se localiza el padre por la tabla asociativa, se avanza una posición —el hueco inmediatamente a su derecha es el candidato natural— y a partir de ahí se avanza mientras el nodo que se encuentre tenga un sello mayor que el del nodo entrante. En cuanto aparece uno con sello menor, ahí va.

function integrar(rep, nodo) {
  let i = rep.lista.indexOf(rep.indice.get(clave(nodo.padre))) + 1;
  while (i < rep.lista.length && comparar(rep.lista[i].id, nodo.id) > 0) i += 1;
  rep.lista.splice(i, 0, nodo);
  rep.indice.set(clave(nodo.id), nodo);
}
flowchart TD
P[localizar el padre por la tabla de sellos] --> A[mirar la posicion siguiente]
A --> C{el sello de aqui es mayor que el mio}
C -->|si| D[avanzar una posicion]
D --> C
C -->|no| E[insertar aqui]
C -->|fin del array| E
style D fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b

Enunciada así, la condición parece incompleta: si voy avanzando por el array, tarde o temprano me saldré del subárbol del padre y pisaré nodos que no son hermanos del entrante. ¿No habría que comprobar la paternidad en cada paso? La respuesta es que no, y la razón es el invariante de la lección anterior. Vale la pena desmontarla en los tres casos que pueden aparecer a la derecha del padre, porque entenderlos es entender RGA entero.

👥

Un hermano con sello mayor

Se salta. Es una inserción concurrente que el orden total coloca antes que la nuestra.

🌿

Un descendiente de ese hermano

Se salta solo. Su sello es mayor que el de su ancestro, que ya era mayor que el nuestro.

🛑

Algo fuera del subárbol

Detiene el barrido. Todo lo que sigue al subárbol del padre tiene sello menor que el padre y por tanto menor que el nuestro.

🎯

Un hermano con sello menor

Detiene el barrido. Es la posición correcta: nuestro nodo se coloca justo delante de él.

La segunda tarjeta es la que hace el trabajo silencioso. Cuando el barrido descarta a un hermano por tener sello mayor, también descarta gratis a toda su descendencia, porque el invariante garantiza que ningún descendiente puede tener un sello menor que su ancestro. No hace falta calcular tamaños de subárbol ni llevar contadores: la aritmética del reloj de Lamport ya ordenó el array de manera que un simple avanza mientras sea mayor recorre exactamente los subárboles que hay que recorrer y se detiene exactamente donde hay que detenerse.

La tercera es la que impide que el barrido se desmande. Lo primero que aparece tras el subárbol de un nodo es un hermano posterior de ese nodo o de alguno de sus ancestros; por la ordenación descendente entre hermanos, ese elemento tiene sello menor que el ancestro correspondiente, que a su vez es menor que el del padre, que a su vez es menor que el nuestro. El bucle se para ahí, sin que nadie se lo haya dicho.

La regla de desempate, ejecutada

Toda la regla está en comparar: gana el contador mayor y, si empatan, el identificador de réplica mayor en orden lexicográfico. El hermano que gana se coloca primero. Veámoslo con dos ráfagas genuinamente concurrentes sobre un documento sincronizado.

const ana = crearReplica("ana"), beto = crearReplica("beto");
const base = escribir(ana, 0, "HOLA");
for (const op of base) recibir(beto, op);      // las dos parten de HOLA

const deAna = escribir(ana, 4, "-uno");        // ana teclea al final
const deBeto = escribir(beto, 4, "-dos");      // beto tambien, sin ver a ana

console.log("sellos de ana :", deAna.map((o) => clave(o.id)).join(" "));
console.log("sellos de beto:", deBeto.map((o) => clave(o.id)).join(" "));
console.log("padres de ana :", deAna.map((o) => clave(o.padre)).join(" "));
console.log("padres de beto:", deBeto.map((o) => clave(o.padre)).join(" "));
console.log("comparar el primero de beto con el primero de ana:", comparar(deBeto[0].id, deAna[0].id));

for (const op of deBeto) recibir(ana, op);
for (const op of deAna) recibir(beto, op);
console.log("resultado: ana =", texto(ana), " beto =", texto(beto));
sellos de ana : 5@ana 6@ana 7@ana 8@ana
sellos de beto: 5@beto 6@beto 7@beto 8@beto
padres de ana : 4@ana 5@ana 6@ana 7@ana
padres de beto: 4@ana 5@beto 6@beto 7@beto
comparar el primero de beto con el primero de ana: 1
resultado: ana = HOLA-dos-uno  beto = HOLA-dos-uno

Esa salida contiene tres hechos que conviene leer despacio. El primero: los dos contadores arrancan en cinco, porque ambas réplicas habían visto exactamente los mismos cuatro caracteres. El empate es real y solo lo rompe el nombre de la réplica, donde beto es lexicográficamente posterior a ana. El segundo: solo el primer nodo de cada ráfaga tiene por padre a 4@ana; los tres siguientes cuelgan del anterior de su propia ráfaga. Por eso los bloques salen enteros y no alternados: la salida es -dos-uno, no una alternancia carácter a carácter de las dos ráfagas. El tercero: las dos réplicas terminan idénticas, y ninguna de las dos ve el texto que tenía localmente hace un instante. Ana escribió HOLA-uno y ahora lee HOLA-dos-uno.

💡
Que el orden final no sea el que tú escribiste no es un error, es la única salida posible

La reacción normal al ver HOLA-dos-uno en la pantalla de Ana es pensar que el algoritmo se equivocó, porque ella escribió primero. No escribió primero: escribió a la vez. Sin comunicación no existe un hecho objetivo sobre quién fue antes, y cualquier regla que pretenda recuperarlo estará inventando información. Lo único que se puede exigir es que la regla sea la misma en todas partes y que no dependa del orden de llegada de los mensajes. Ahí es donde el identificador de réplica hace su trabajo, y por eso conviene que sea aleatorio y no un nombre con significado: si eliges identificadores como servidor y movil, acabarás con un sesgo sistemático a favor de uno de ellos que nadie pidió y que aparecerá en cada empate durante toda la vida del documento.

Aplicar, aceptar y encolar: la inserción completa

Falta cerrar el circuito entre la operación que llega por la red y la integración. La función aplicar es la frontera: comprueba que el padre existe, descarta duplicados, sincroniza el reloj e integra. Devuelve un booleano, y ese booleano es el que decide si la operación se consume o se guarda para más tarde.

function aplicar(rep, op) {
  if (op.tipo === "insertar") {
    if (!rep.indice.has(clave(op.padre))) return false;   // aun no tengo el ancla
    if (rep.indice.has(clave(op.id))) return true;        // ya la tenia: idempotente
    rep.reloj = Math.max(rep.reloj, op.id.contador);      // regla dos del reloj
    integrar(rep, { id: op.id, padre: op.padre, valor: op.valor, borrado: false });
    return true;
  }
  const objetivo = rep.indice.get(clave(op.id));
  if (!objetivo) return false;
  objetivo.borrado = true;
  return true;
}

function recibir(rep, op) {
  rep.pendientes.push(op);
  let progreso = true;
  while (progreso) {                                      // reintentar hasta estancarse
    progreso = false;
    for (let i = 0; i < rep.pendientes.length; i += 1) {
      if (aplicar(rep, rep.pendientes[i])) {
        rep.pendientes.splice(i, 1); i -= 1; progreso = true;
      }
    }
  }
}

Las dos primeras líneas de aplicar son las que hacen que el resto del nivel funcione. La comprobación del padre convierte la exigencia teórica de entrega causal en una condición local y verificable: si falta el ancla, la operación no se pierde, se aparca. La comprobación de duplicado hace la operación idempotente, de modo que reenviar el mismo mensaje cien veces no cambia nada. Juntas permiten un transporte tonto —difusión sin garantías, reintentos ciegos, reconexiones que reenvían de más— sin que la corrección dependa de él.

El desempate por sello no elige el orden correcto: elige un orden cualquiera y renuncia a saber cuál era el correcto, y esa renuncia es la que hace el algoritmo barato

Conviene ser explícito sobre lo que acaba de ocurrir en esa línea de comparar, porque es el punto donde RGA se separa de toda la tradición anterior y donde se decide su coste. La transformación operacional se pasó dos décadas intentando reconstruir la intención: dadas dos ediciones concurrentes, calcular cómo habría quedado el documento si se hubieran aplicado en el orden en que ocurrieron de verdad, y para ello mantenía historiales, comparaba contextos y aplicaba una matriz de transformaciones caso por caso cuya corrección resultó demostrablemente difícil de garantizar. RGA hace lo contrario: declara que la pregunta no tiene respuesta y elige un ganador con un criterio que no pretende ser justo, solo idéntico en todas partes. Comparar dos enteros y desempatar por una cadena no reconstruye nada; ni siquiera lo intenta. Y precisamente por no intentarlo, la operación de integración no necesita conocer la historia, no necesita el estado global, no necesita saber qué otras operaciones existen: le basta con el nodo entrante y con el trozo de array que hay a la derecha de su padre. De ahí salen, en cascada, todas las propiedades que hacen viable la familia. La integración es local, así que su coste no crece con el tamaño del documento sino con el número de hermanos concurrentes, que en una sesión humana real son dos o tres. Las operaciones conmutan, así que el transporte puede entregarlas en cualquier orden. Son idempotentes, así que se pueden reenviar sin contabilidad. Y no hay estado oculto, así que una réplica que estuvo un año desconectada se pone al día aplicando lo que le falta, sin ninguna fase de reconciliación. Nada de eso es gratis: el precio es exactamente la anomalía del entrelazado del nivel 33, que es la factura de haber renunciado a la intención. La lección que conviene llevarse más allá de este algoritmo es que en un sistema distribuido, decidir menos suele salir más barato que decidir mejor, y que casi siempre el error de diseño consiste en intentar recuperar información que la ausencia de coordinación destruyó de forma irreversible. La pregunta útil no es cómo averiguar quién escribió primero, sino qué se puede construir aceptando que eso no se puede saber.

⚔️ Escribir la inserción y romperla a propósito
  1. Implementa insertar, integrar, aplicar y recibir y reproduce exactamente la salida de sellos de la tercera sección.
  2. Quita la sincronización del reloj en el receptor y busca el caso concreto en el que las réplicas dejan de converger.
  3. Invierte la regla de desempate para que gane el sello menor y comprueba que sigue convergiendo, pero al texto contrario.
  4. Sustituye el desempate por réplica por una comparación numérica del contador solamente y construye el caso que falla.
  5. Instrumenta el bucle de integrar para contar cuántos nodos salta en una sesión con tres réplicas escribiendo a la vez.
  6. Entrega a una réplica una inserción antes que su padre y verifica que queda en pendientes y se resuelve al llegar el ancla.