wandres.dev
REGISTROS · un solo valor, varios dueños

Registros dentro de estructuras: el valor que es otro CRDT

Los registros casi nunca viven solos sino como valores de un mapa o de una lista, y ahí aparece el detalle que rompe implementaciones enteras cuando el valor de un registro es a su vez una estructura convergente.

⏱ 21 min

Hasta aquí el registro ha vivido solo, y en un modelo real eso no ocurre nunca: vive como valor de un mapa que representa un documento, o como campo de un elemento de una lista, o dentro de otro mapa a tres niveles de profundidad. La buena noticia es que la composición más obvia funciona sin sorpresas, porque el producto de retículos es un retículo y fusionar un mapa punto a punto conserva las tres leyes. La mala es que hay dos sitios donde la composición se rompe y ninguno de los dos avisa. El primero es la clave: si la identidad de una entrada depende de su posición, la fusión empareja registros que no eran el mismo. El segundo, y es el que arruina implementaciones que por lo demás estaban bien, aparece cuando el valor de un registro es a su vez una estructura convergente, porque en ese momento el registro está eligiendo entre dos objetos que sabían fusionarse y tirando uno.

🎯 Al terminar esta lección sabrás
  • Componer registros dentro de mapas y listas conservando las tres leyes del semirretículo.
  • Entender por qué la clave de una entrada nunca puede ser su posición y qué la sustituye.
  • Resolver el conflicto entre asignar una clave y borrarla concurrentemente sin resucitar ni perder.
  • Identificar y corregir el fallo de anidamiento que hace que un contador dentro de un registro pierda incrementos.

El mapa, y la clave que no puede ser la posición

Un documento es un mapa de nombre de campo a registro, y su fusión es punto a punto: cada clave se fusiona con la política de su campo, las claves que solo están en un lado se copian tal cual. Si cada componente cumple las tres leyes, el mapa las cumple, porque el producto de semirretículos es un semirretículo y la demostración es componente a componente.

function fusionarMapa(a, b, fusionarValor) {
  const salida = {};
  for (const clave of new Set([...Object.keys(a), ...Object.keys(b)])) {
    if (!(clave in a)) salida[clave] = b[clave];
    else if (!(clave in b)) salida[clave] = a[clave];
    else salida[clave] = fusionarValor(clave, a[clave], b[clave]);
  }
  return salida;
}

La trampa aparece al bajar a listas, y es de las que se descubren tarde y en producción. Si los elementos de una lista se identifican por su índice, dos réplicas que insertan concurrentemente en posiciones distintas desplazan las siguientes de forma diferente, y la fusión punto a punto acaba emparejando el registro del elemento tercero de una con el del elemento tercero de la otra, que no son el mismo elemento. El resultado no es una divergencia —converge perfectamente— sino un objeto híbrido con el título de una tarea y la fecha de otra, que nadie escribió jamás.

La corrección es que cada elemento lleve un identificador estable creado al nacer, independiente de su posición y nunca reutilizado, y que la lista guarde el orden por separado como un problema aparte. Es la misma idea que sostiene los conjuntos con semántica de observación y las secuencias de los niveles siguientes, y aquí basta con enunciarla como restricción: la clave de fusión identifica al objeto, no a su sitio.

⚠️
Ningún índice, ninguna ruta, ningún hash del contenido

Las tres tentaciones habituales para identificar un elemento sin inventar identificadores fallan por el mismo motivo. El índice cambia al insertar delante. La ruta dentro del documento cambia al mover el elemento. El hash del contenido cambia al editarlo, y además fusiona por accidente dos elementos que casualmente tenían el mismo valor. Un identificador estable no se deriva del estado: se genera una vez y se transporta.

Asignar mientras otro borra

El segundo conflicto propio del mapa no es entre dos escrituras sino entre una escritura y una eliminación de la misma clave. Si borrar consiste en quitar la entrada, la réplica que la borró tiene un mapa sin la clave y la que escribió tiene un mapa con ella; la fusión punto a punto copia lo que solo está en un lado, así que la clave sobrevive siempre y el borrado nunca se propaga. El dato resucita.

La solución estándar es que el borrado también sea información: una lápida con contexto causal que participa en la fusión igual que un valor. Con eso hay dos semánticas defendibles y hay que elegir explícitamente. Gana la escritura conserva la clave si la asignación fue concurrente con el borrado, y es la que casi siempre quiere una aplicación, porque borrar sin haber visto la edición ajena no es una decisión informada. Gana el borrado hace lo contrario y solo tiene sentido cuando eliminar es una acción de seguridad o de cumplimiento que debe prevalecer.

// La entrada guarda su ultima escritura y su ultima baja, ambas con contexto causal
function fusionarEntrada(a, b) {
  const escritura = a.escritura && b.escritura ? fusionarLWW(a.escritura, b.escritura)
    : a.escritura ?? b.escritura;
  const baja = a.baja && b.baja ? mayor(a.baja, b.baja) : a.baja ?? b.baja;
  return { escritura, baja };
}

// Gana la escritura: la baja solo se aplica si domino causalmente a la escritura
function visible(entrada) {
  if (!entrada.baja) return true;
  if (!entrada.escritura) return false;
  return !dominaOigual(entrada.baja.vv, entrada.escritura.vv);
}

Nótese que decidir cuál gana exige comparar contexto causal, no sellos: preguntar cuál tiene la marca mayor devolvería una respuesta para el caso concurrente, que es justo el caso en el que la política tiene que pronunciarse a propósito. Y nótese también que las lápidas hay que conservarlas mientras exista alguna réplica que no las haya visto, lo cual convierte la poda en el mismo problema de siempre: podar demasiado pronto resucita.

El detalle que rompe: cuando el valor es otro CRDT

Aquí está el fallo que arruina implementaciones que hasta este punto eran correctas, y es tan silencioso como los anteriores. Supongamos un campo cuyo valor es un contador replicado, que por construcción no puede perder incrementos porque su fusión los suma. Si ese contador está guardado dentro de un registro de última escritura, la fusión del registro compara sellos, se queda con un lado entero y tira el otro. El contador ha perdido incrementos. La garantía del componente ha desaparecido sin que el componente tuviera ningún defecto.

// Contador replicado: cada replica lleva su total y la fusion toma el maximo
function fusionarContador(a, b) {
  const r = { ...a };
  for (const k of Object.keys(b)) r[k] = Math.max(r[k] ?? 0, b[k]);
  return r;
}

// INCORRECTO: el registro trata al contador como un valor opaco
function fusionarRegistroOpaco(a, b) {
  return menor(a.sello, b.sello) ? b : a;
}

const enA = { oid: "c1", sello: sA, valor: { A: 7, B: 5 } }; // A sumo dos
const enB = { oid: "c1", sello: sB, valor: { A: 5, B: 8 } }; // B sumo tres

fusionarRegistroOpaco(enA, enB).valor; // -> un lado entero: se pierden dos o tres
fusionarContador(enA.valor, enB.valor); // -> A siete y B ocho: no se pierde nada

La corrección pasa por una idea que las librerías maduras tienen en el centro de su modelo y que a mano casi nadie pone: los contenedores tienen identidad de objeto. Al crear un contenedor se le asigna un identificador único que viaja con él y que no se deriva ni de la clave ni del contenido. Con esa identidad, la fusión del registro puede distinguir los dos casos que hasta ahora estaba confundiendo. Si ambos lados apuntan al mismo objeto, la asignación no ocurrió y lo que hay son ediciones concurrentes dentro del contenedor, así que hay que fusionar hacia dentro con la fusión del componente. Si apuntan a objetos distintos, hubo dos asignaciones concurrentes de contenedores diferentes, y ahí sí hay una elección que hacer.

const mayor = (x, y) => (menor(x, y) ? y : x);

function fusionarRegistroAnidado(a, b, fusionarValor) {
  if (a.oid === b.oid) {
    // Mismo objeto: nadie asigno, hay ediciones concurrentes dentro
    return { oid: a.oid, sello: mayor(a.sello, b.sello), valor: fusionarValor(a.valor, b.valor) };
  }
  // Objetos distintos: dos asignaciones concurrentes, y la perdedora queda alcanzable
  const gana = menor(a.sello, b.sello) ? b : a;
  const pierde = gana === a ? b : a;
  return { ...gana, huerfanos: [...(gana.huerfanos ?? []), pierde] };
}

Fusionar hacia dentro cuando los identificadores difieren sería igual de incorrecto que no hacerlo nunca, y conviene entender por qué: dos contenedores creados por separado no comparten historia, así que sumar sus contadores o unir sus conjuntos fabricaría un objeto que ninguna de las dos personas escribió ni pidió. La identidad es precisamente lo que autoriza a fusionar; sin ella, la fusión inventa.

flowchart TB
R[registro cuyo valor es un contenedor] --> C[comparar identidad de objeto]
C -->|misma identidad| D[fusionar hacia dentro con el supremo del componente]
C -->|identidad distinta| E[elegir por sello y conservar la perdedora]
D --> G[no se pierde ningun incremento]
E --> H[la perdedora sigue alcanzable como huerfana]
style D fill:#a6e3a1,color:#11111b
style G fill:#a6e3a1,color:#11111b
style E fill:#f9e2af,color:#11111b
💡
La perdedora no debe desaparecer, debe dejar de ser alcanzable

Cuando dos asignaciones de contenedor compiten, el objeto perdedor no tiene por qué destruirse: basta con que deje de estar en el camino de lectura habitual. Las librerías serias hacen exactamente esto y ofrecen una operación para consultar las versiones en conflicto de un campo, de modo que la interfaz puede enseñarlas si quiere. El coste es un puntero por conflicto, la duración es hasta la siguiente asignación que domine a ambas, y a cambio la pérdida deja de ser irreversible.

La regla de composición

Todo lo anterior se resume en una regla que conviene escribir en el módulo de fusión, porque es la que se viola por descuido: un registro solo puede tratar su valor como opaco si ese valor no tiene fusión propia. Los escalares inmutables —un número, una cadena, una fecha, un enumerado— la cumplen y para ellos la última escritura es legítima. En cuanto el valor es un mapa, una lista, un conjunto, un contador o cualquier otra cosa con estructura interna replicada, el campo ha dejado de ser un registro de valor y es una referencia a un objeto, y las referencias se fusionan comparando identidad antes que sello.

De ahí sale también la razón por la que el atajo más popular del sector es también el más caro. Meter un objeto entero serializado dentro de un campo de última escritura es cómodo, funciona en la demostración y convierte cada campo interno de ese objeto en una víctima: dos personas que editan atributos distintos del mismo objeto anidado pierden una entera, y ninguna estructura convergente que hubiera dentro conserva su garantía. No es un problema del formato ni del tamaño; es que la serialización borra la identidad de los objetos internos y sin identidad no hay fusión posible.

🆔

Identidad al nacer

Cada contenedor recibe un identificador único que no se deriva de la clave, la posición ni el contenido, y que viaja con él para siempre.

🪆

Misma identidad, fusión hacia dentro

Si los dos lados son el mismo objeto no hubo asignación, hubo edición concurrente, y la fusión correcta es la del componente.

🧷

Identidad distinta, elección con rastro

Dos contenedores creados por separado no se mezclan. Se elige uno y el otro queda alcanzable como versión en conflicto.

📦

Nunca un objeto serializado

Un blob dentro de un registro destruye la identidad de todo lo que contiene y con ella cualquier garantía de sus componentes.

Las garantías de un CRDT no son composicionales por defecto, y su pérdida no produce ningún síntoma

El aprendizaje que hay que llevarse de este nivel entero, y que va bastante más allá de los registros, es que la propiedad que tanto trabajo cuesta demostrar para una estructura individual no se hereda automáticamente al meterla dentro de otra. Estamos acostumbrados a que la composición sea segura porque en la mayor parte de la programación lo es: una función correcta llamada desde otra función sigue siendo correcta, un tipo válido dentro de otro tipo sigue siendo válido. Aquí no. Un contador que no puede perder incrementos, colocado dentro de un registro que elige por sello, pierde incrementos, y no porque el contador fallara ni porque el registro fallara, sino porque el registro sustituyó el supremo del contador por una decisión propia. La garantía no vivía en el contador: vivía en que alguien invocara su fusión, y el contenedor dejó de invocarla. Lo que hace este fallo tan duradero es que el resultado sigue superando todas las pruebas que solemos aplicar. El documento converge, porque elegir por sello es conmutativo, asociativo e idempotente; el verificador de semirretículo pasa en verde; dos réplicas que intercambien estados terminan idénticas; la telemetría no registra nada porque no ocurrió nada anómalo. El sistema converge y miente, que es la combinación exacta que hemos ido encontrando en cada lección de este nivel y que conviene reconocer ya como una firma. Cuando un diseño replicado falla, casi nunca falla divergiendo: falla acordando algo que no respeta lo que nadie quiso. La consecuencia práctica es que la revisión de un modelo replicado tiene que hacerse en dos pasadas independientes y con criterios distintos. La primera pregunta si converge, y se responde con álgebra sobre la función de fusión de cada componente. La segunda pregunta si cada componente conserva su propia garantía dentro del contenedor que lo aloja, y esa no se responde con álgebra sino recorriendo el árbol del documento nodo a nodo y comprobando que en ningún punto del camino hay un contenedor que trate a su hijo como opaco. Un solo campo mal compuesto en un modelo de cuarenta anula, para todo lo que cuelgue de él, el trabajo de los quince niveles que dedicaste a las estructuras convergentes, y lo hace sin emitir un solo error. Por eso las librerías que resuelven esto de verdad no ofrecen tipos sueltos que puedas anidar a tu gusto, sino un modelo de documento completo con identidad de objeto integrada, y por eso su rigidez aparente —no puedes meter cualquier cosa en cualquier sitio— no es una limitación de diseño sino exactamente la propiedad que estás pagando.

⚔️ Anida y comprueba
  1. Implementa el mapa de registros con fusión punto a punto y verifica las tres leyes sobre documentos generados al azar.
  2. Modela una lista identificando elementos por índice, provoca inserciones concurrentes y encuentra el objeto híbrido que nadie escribió.
  3. Repite el experimento con identificadores estables y comprueba que los campos ya no se cruzan entre elementos distintos.
  4. Implementa las dos semánticas de borrado contra escritura y decide cuál quiere tu producto para cada colección, por escrito.
  5. Mete un contador dentro de un registro opaco, genera incrementos concurrentes desde tres réplicas y mide exactamente cuántos desaparecen.
  6. Añade identidad de objeto y fusión hacia dentro, repite la medición y comprueba que el total vuelve a ser la suma real de los incrementos.