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

El problema del registro: un valor, muchos dueños

Un registro es el tipo de dato más simple que existe y el más difícil de replicar, porque asignar es la única operación elemental que destruye información en lugar de acumularla.

⏱ 18 min

Un registro es una celda: guarda un valor, lo lees, escribes encima. En una máquina sola es el tipo de dato más aburrido que existe, tan aburrido que ni siquiera lo llamamos tipo de dato; lo llamamos variable. Replicado entre dispositivos que se editan sin verse, ese mismo objeto trivial se convierte en el más difícil de los tipos elementales, y lo es por una razón que conviene tener delante durante los cinco niveles siguientes: incrementar acumula, insertar acumula, asignar destruye. Un contador converge porque dos incrementos concurrentes se pueden sumar; un conjunto converge porque dos inserciones concurrentes se pueden unir; dos asignaciones concurrentes no se pueden hacer las dos. Y sin embargo el registro es, con enorme diferencia, lo que hay dentro de una aplicación real: el título, el estado, la fecha límite, el responsable, el precio, el color de fondo. Es el tipo más frecuente del modelo y el peor resuelto del sector.

🎯 Al terminar esta lección sabrás
  • Definir el registro como tipo abstracto y aislar la propiedad que lo hace difícil: la asignación no conmuta.
  • Entender por qué la técnica que hace converger a contadores y conjuntos no se traslada tal cual.
  • Enunciar la ecuación que gobierna todo el nivel: el estado replicado es el valor más un metadato.
  • Reconocer las tres únicas salidas posibles y por qué la mayoría de las aplicaciones no ha elegido ninguna conscientemente.

El tipo más simple, y lo que le pasa al replicarlo

La especificación secuencial de un registro cabe en una línea: leer devuelve el valor de la última asignar. Esa definición se apoya entera en la palabra última, y la palabra última presupone un orden total sobre las escrituras. En un proceso único ese orden lo regala el programa. Entre réplicas que editan sin verse, no existe: hay pares de escrituras que ninguna precede a la otra, y la especificación se queda literalmente sin significado. No es que el registro sea difícil de implementar; es que su definición deja de nombrar un objeto.

Conviene ver la divergencia en su forma mínima antes de razonar sobre ella, porque es más tonta de lo que uno espera y aparece sin necesidad de ningún escenario rebuscado.

// Registro ingenuo: el valor y nada mas
const crear = (valor) => ({ valor });
const asignar = (reg, nuevo) => ({ valor: nuevo });

// La fusion no tiene ninguna informacion con la que decidir
const fusionar = (a, b) => a; // cualquier eleccion aqui es arbitraria

const ana = asignar(crear("borrador"), "Informe Q1");
const luis = asignar(crear("borrador"), "Informe primer trimestre");

fusionar(ana, luis).valor; // -> Informe Q1
fusionar(luis, ana).valor; // -> Informe primer trimestre

Las dos últimas líneas son el problema entero. La fusión depende del orden de sus argumentos, y el orden de los argumentos depende de qué réplica llamó a quién, así que dos dispositivos que han visto exactamente las mismas escrituras terminan con estados distintos. El resultado no es un error visible ni una excepción: es una divergencia permanente que cada réplica considera el estado correcto.

La condición que hay que exigirle a la fusión para que eso no ocurra se conoce desde los trabajos de Shapiro y sus colaboradores en 2011, y no es negociable: la función tiene que ser conmutativa, asociativa e idempotente, es decir, tiene que ser el supremo de un semirretículo. Merece la pena escribirla como una comprobación ejecutable, porque es la prueba que hay que pasarle a cualquier registro que diseñes en el resto del nivel.

const igual = (x, y) => JSON.stringify(x) === JSON.stringify(y);

function verificarSemirreticulo(fusionar, muestras) {
  for (const a of muestras) {
    if (!igual(fusionar(a, a), a)) return "falla la idempotencia";
    for (const b of muestras) {
      if (!igual(fusionar(a, b), fusionar(b, a))) return "falla la conmutatividad";
      for (const c of muestras) {
        const izquierda = fusionar(fusionar(a, b), c);
        const derecha = fusionar(a, fusionar(b, c));
        if (!igual(izquierda, derecha)) return "falla la asociatividad";
      }
    }
  }
  return "cumple las tres";
}

verificarSemirreticulo(fusionar, [ana, luis]); // -> falla la conmutatividad

Asignar destruye, y por eso hay que enriquecer el estado

La pregunta correcta no es por qué falla el registro, sino por qué no fallan los otros. Un contador converge porque el efecto de un incremento es acumulativo: el estado después de dos incrementos contiene a los dos, y la fusión solo tiene que evitar contarlos dos veces. Un conjunto converge por lo mismo: la unión de dos inserciones contiene ambas. En los dos casos el estado crece de forma monótona, y sobre un estado que solo crece siempre se puede definir un supremo.

La asignación rompe justo esa propiedad. Su efecto no es añadir sino sustituir, de modo que el estado después de dos asignaciones no contiene a las dos: contiene a una. Un tipo cuyo estado no crece no vive en ningún retículo, y sin retículo no hay convergencia posible. Esta es la razón profunda, y no un detalle de implementación, por la que no existe el registro replicado: existen varios, y cada uno es una manera distinta de fabricar artificialmente el crecimiento que la asignación no tiene.

De ahí sale la ecuación que gobierna el nivel entero y que conviene memorizar tal cual: el estado replicado es el valor más un metadato. El valor es lo que el usuario cree que está guardando; el metadato es lo que hace posible el supremo. Toda la variedad de registros que verás en las lecciones siguientes se reduce a dos decisiones sobre esa segunda mitad: qué metadato se adjunta y cómo la lectura vuelve a proyectar el estado enriquecido sobre algo que la aplicación pueda pintar.

flowchart LR
V[valor que el usuario escribe] --> E[estado replicado]
M[metadato que ordena o distingue] --> E
E --> F[supremo bien definido]
F --> L[lectura que proyecta a la interfaz]
L --> U[un valor unico o un conjunto]
style E fill:#89b4fa,color:#11111b
style U fill:#f9e2af,color:#11111b

Escrito como código, ese patrón es el molde del que salen las dos construcciones del nivel. Un registro replicado son siempre tres funciones: una que enriquece la escritura, una que fusiona estados enriquecidos y una que proyecta el resultado hacia la aplicación. Cambiar de política es cambiar las tres a la vez, nunca una sola.

// El molde. Las lecciones 2 y 3 son dos rellenos distintos de estas tres ranuras
function definirRegistro({ enriquecer, supremo, proyectar }) {
  return {
    asignar: (contexto, valor) => enriquecer(contexto, valor),
    fusionar: (a, b) => supremo(a, b),
    leer: (estado) => proyectar(estado),
  };
}

La ranura que decide todo lo demás es la tercera, y por eso conviene mirarla antes que ninguna. Si proyectar devuelve un valor, la aplicación de arriba no tiene que enterarse de nada y el precio está enteramente escondido dentro de supremo, que habrá tenido que descartar algo. Si proyectar devuelve un conjunto, no se descarta nada y el precio sube hasta la interfaz, que ya no puede pintar el campo sin decidir qué hace con dos. La elección entre las dos políticas del nivel es, literalmente, la elección del tipo de retorno de esa función.

ℹ️
El metadato no es un impuesto, es la estructura

Es tentador leer el metadato como sobrecoste que algún día se optimizará. No lo es: es lo único que hace que el objeto tenga fusión. Un registro sin metadato no es un registro más barato, es un registro que no converge, del mismo modo que un conjunto sin identificadores únicos no es un conjunto más ligero sino uno que resucita elementos borrados. Cuando alguien proponga quitarlo para ahorrar bytes, la pregunta correcta es qué propiedad algebraica se está proponiendo perder.

Las tres salidas, y ninguna es gratis

Solo hay tres maneras de fabricar el crecimiento que falta, y todo lo que existe en producción es una de las tres o una mezcla explícita de ellas. La primera es ordenar: adjuntar una marca que induzca un orden total sobre las escrituras, redefinir última como la de marca mayor y quedarse con ella. La segunda es conservar: adjuntar contexto causal, detectar que dos escrituras fueron concurrentes y devolver las dos, trasladando la decisión a quien pueda tomarla. La tercera es descomponer: reconocer que ese campo no debería haber sido un registro, y sustituirlo por una estructura cuya operación sí conmute —un contador, un conjunto, una secuencia—.

La tercera es la más potente y la menos aplicada, porque exige cambiar el modelo de datos en lugar de cambiar la librería de sincronización. Un campo etiquetas modelado como registro de una lista pierde las etiquetas que añadió el otro; modelado como conjunto con semántica de observación no pierde ninguna. La diferencia no está en el motor: está en haber mirado el campo y haber preguntado qué operación estaba ocurriendo de verdad debajo de la asignación.

⏱️

Ordenar

Adjuntas un sello y te quedas con el mayor. Coste constante, convergencia inmediata, y la escritura perdedora desaparece sin dejar rastro. Es la lección dos.

🍒

Conservar

Adjuntas contexto causal y devuelves el conjunto de valores concurrentes. No pierdes nada y obligas a la interfaz a saber mostrar un conflicto. Es la lección tres.

🧩

Descomponer

Descubres que el campo era un conjunto, un contador o un texto disfrazados de asignación, y usas la estructura que sí conmuta. Es el resto del track.

🙋

Preguntar

Ninguna de las tres resuelve un conflicto de intención. Cuando dos personas quisieron cosas incompatibles, la única respuesta honesta es enseñárselo a alguien.

⚠️
La cuarta salida no existe, aunque se vende

Periódicamente aparece la promesa de una fusión inteligente que decide sola el valor correcto sin metadatos ni intervención. Esa función no puede existir, y la demostración es de una línea: si la fusión solo ve dos valores y ninguna información sobre cómo llegaron ahí, no puede distinguir el caso en que uno sucedió después del otro del caso en que fueron simultáneos, y esos dos casos exigen respuestas distintas. Lo que se vende como inteligencia es siempre una de las tres salidas con el metadato escondido, o un gana el último con el reloj del sistema.

Por qué es el caso más común y el peor resuelto

Cuenta los campos de cualquier modelo real. La inmensa mayoría son registros: cadenas cortas, fechas, enumerados, referencias, números que no se incrementan sino que se fijan. Los contadores son un puñado, los conjuntos unos pocos, el texto colaborativo suele ser uno o dos campos de todo el producto. Y sin embargo la literatura, las conferencias y las demostraciones se dedican casi por completo al texto colaborativo, porque es el caso vistoso, mientras que el campo estado de una tarea —que se edita cien veces más y se pierde cien veces más— no aparece en ninguna parte.

El desequilibrio tiene una consecuencia concreta y medible en cualquier producto en producción: el texto está bien resuelto por una librería madura y todo lo que lo rodea está resuelto por el valor por defecto de esa misma librería, que casi siempre es la primera salida sin que nadie la haya elegido. La pérdida no se concentra donde se puso el esfuerzo; se concentra en los cuarenta campos sobre los que no se pensó.

Hay además una razón estructural por la que el problema no se corrige solo con el tiempo, y es la ausencia de realimentación. Un fallo de sincronización que rompe la aplicación se arregla en horas porque interrumpe a alguien; un registro que descarta la escritura de un usuario no interrumpe a nadie, no genera excepción, no aparece en la telemetría y produce como único síntoma una incidencia de soporte imposible de reproducir. El sistema no tiene manera de saber que perdió algo, así que tampoco puede contarlo, y lo que no se cuenta no entra nunca en la lista de prioridades.

📝
La primera métrica de este nivel es cuántos valores estás descartando

Antes de cambiar ninguna política merece la pena instrumentar la que ya tienes. Basta con anotar, en cada fusión que descarte un valor, qué campo era, qué valor se fue y qué réplica lo escribió. En una semana de uso real esa tabla ordena por sí sola el trabajo del resto del nivel, y suele contener dos sorpresas: un campo que descarta muchísimo más de lo que nadie esperaba, y una mayoría de campos que no descarta nunca y sobre los que no hace falta discutir nada.

El registro es el punto donde la convergencia y la intención se separan del todo

Merece la pena entender por qué este tipo tan pequeño se lleva un nivel entero, porque la respuesta reorganiza cómo se lee cualquier sistema replicado. En un contador y en un conjunto, la convergencia y la preservación de la intención coinciden: si dos personas incrementan, la suma respeta lo que quisieron las dos; si dos personas insertan, la unión respeta lo que quisieron las dos. La estructura algebraica y la voluntad humana apuntan en la misma dirección, y por eso esos tipos se sienten mágicos: se comportan bien sin que nadie tenga que decidir nada. El registro es el punto exacto donde esa coincidencia se rompe, y se rompe por una razón que no es técnica sino semántica: dos asignaciones concurrentes al mismo campo son, en el caso general, dos afirmaciones incompatibles sobre el mundo. Si una persona marca la tarea como terminada y otra la marca como cancelada, no hay ninguna operación matemática que respete a las dos, porque no existe un estado que sea a la vez terminado y cancelado. Ninguna cantidad de teoría de retículos va a inventar ese estado. Lo que la teoría puede hacer, y hace muy bien, es garantizar que todas las réplicas acaben viendo lo mismo; lo que no puede hacer es garantizar que ese algo respete lo que ambas personas querían, porque eso era imposible desde antes de que existiera el sistema. De ahí se sigue la disciplina que hay que adoptar para el resto del nivel y que va contra el instinto de todo ingeniero. Ante un campo replicado, la primera pregunta no es qué estructura uso, sino si el conflicto que puede darse ahí es un conflicto de representación o un conflicto de intención. Los de representación —dos formas de escribir el mismo hecho, dos ediciones que tocan partes distintas, dos incrementos del mismo total— los resuelve la estructura, y elegir bien la estructura los hace desaparecer. Los de intención —terminado contra cancelado, mi versión del párrafo contra la tuya— no los resuelve ninguna estructura, y el único diseño honesto consiste en decidir explícitamente quién carga con ellos: el algoritmo, que elegirá arbitrariamente y en silencio, o la persona, a la que habrá que enseñarle las dos opciones y molestarla. Esa decisión es de producto, no de ingeniería, y es exactamente la que se toma por defecto cuando nadie la toma. Las tres lecciones siguientes son las dos respuestas técnicas a esa pregunta y el criterio para escoger entre ellas; pero la pregunta hay que hacerla campo a campo, y hacerla es todo el trabajo.

⚔️ Inventaria tus registros
  1. Recorre el modelo de datos de una aplicación tuya y clasifica cada campo en registro, contador, conjunto o secuencia. Cuenta cuántos hay de cada uno.
  2. Para los que salieron registro, anota qué política los fusiona hoy y en qué línea de qué dependencia está escrita esa política.
  3. Implementa el verificador de semirretículo de esta lección y pásaselo a la función de fusión que estés usando de verdad.
  4. Busca al menos un campo que sea un conjunto disfrazado de registro y reescríbelo como conjunto; mide qué escrituras deja de perder.
  5. Para tres campos concretos, decide si un conflicto ahí sería de representación o de intención, y justifica la respuesta en una frase.
  6. Simula dos asignaciones concurrentes en producción con relojes desincronizados y comprueba si tu sistema deja algún rastro de la perdedora.