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

LWW-Register: comprar un orden total y pagarlo con datos

El registro de última escritura fabrica un orden total adjuntando un sello a cada asignación, y su corrección depende por completo de qué reloj produce ese sello y de si la réplica lo alimenta al recibir.

⏱ 20 min

La primera salida del problema del registro consiste en negarse a aceptar que existan escrituras incomparables: si la especificación secuencial necesita una última, se fabrica una. Se adjunta a cada asignación un sello, se declara que la mayor gana y el tipo pasa a tener supremo. Es una construcción de ocho líneas, converge sin discusión, cuesta un campo por dato y no obliga a pensar en el modelo, y por eso es la política que trae de fábrica prácticamente cualquier motor de sincronización que instales. Lo que casi nadie mira es de dónde sale el sello, y ahí está todo. Con el reloj del sistema, el registro elige al dispositivo peor calibrado y lo llama último. Con un reloj lógico de los niveles veintiuno y veintidós, elige de una manera defendible y reproducible. La diferencia entre las dos versiones es de treinta líneas de código y de una categoría entera de incidencias que nunca se cierran.

🎯 Al terminar esta lección sabrás
  • Implementar el registro de última escritura sobre un reloj lógico híbrido en lugar de sobre la hora del sistema.
  • Entender por qué el desempate por identidad de réplica es obligatorio y por qué su arbitrariedad es inofensiva.
  • Identificar el error de implementación que hace perder escrituras aunque el reloj sea correcto.
  • Enumerar con precisión qué se pierde y qué variantes reducen el daño sin cambiar de política.

La construcción, y el reloj que la sostiene

El sello no puede ser la hora del sistema, y la lección tres del nivel veintitrés dejó claro por qué: la deriva de un reloj es un sesgo persistente, no un ruido, así que un dispositivo adelantado gana estructuralmente todas las disputas y uno atrasado no gana ninguna. El reloj híbrido del nivel veintidós resuelve la parte que se puede resolver: nunca retrocede, respeta la causalidad y se mantiene cerca de la hora real. Empecemos por él, porque el registro es un envoltorio fino sobre esa pieza.

// Reloj hibrido del nivel 22: valor fisico anclado mas contador de desempate
function crearReloj(id, ahora = () => Date.now()) {
  return { id, l: 0, c: 0, ahora };
}

function sellar(r) {
  const previo = r.l;
  r.l = Math.max(previo, r.ahora());
  r.c = r.l === previo ? r.c + 1 : 0;
  return { l: r.l, c: r.c, id: r.id };
}

function observar(r, s) {
  const previo = r.l;
  r.l = Math.max(previo, s.l, r.ahora());
  if (r.l === previo && r.l === s.l) r.c = Math.max(r.c, s.c) + 1;
  else if (r.l === previo) r.c = r.c + 1;
  else if (r.l === s.l) r.c = s.c + 1;
  else r.c = 0;
}

// Orden total estricto: fisico, contador y por ultimo identidad
function menor(x, y) {
  if (x.l !== y.l) return x.l < y.l;
  if (x.c !== y.c) return x.c < y.c;
  return x.id < y.id;
}

Con eso el registro es casi anecdótico, y esa brevedad es exactamente su atractivo comercial.

const crearLWW = (valor, sello) => ({ valor, sello });

function asignarLWW(reloj, valor) {
  return { valor, sello: sellar(reloj) };
}

// Conmutativa, asociativa e idempotente porque el orden es total y estricto
function fusionarLWW(a, b) {
  return menor(a.sello, b.sello) ? b : a;
}

const leerLWW = (reg) => reg.valor;

Conviene comprobar las tres leyes en lugar de creerlas. La conmutatividad sale de que menor es un orden total: para cualquier par distinto, uno de los dos es estrictamente menor, y la respuesta no depende de la posición del argumento. La asociatividad sale de que quedarse con el mayor de tres es independiente del paréntesis. Y la idempotencia sale de que menor es estricto: comparado consigo mismo devuelve falso y la fusión conserva el primero, que es el mismo objeto. Si alguien relaja menor a un menor o igual, la idempotencia se mantiene por casualidad y la conmutatividad se rompe, que es el fallo más habitual al escribir esta función a mano.

El desempate y el error que arruina la construcción

El tercer criterio, la identidad de la réplica, no es un adorno defensivo: es obligatorio. Dos réplicas pueden producir el mismo par físico y lógico —es raro pero no imposible, y con relojes de baja resolución deja de ser raro—, y si dos sellos empatan, cada réplica elegiría el suyo y la divergencia sería permanente. Cualquier criterio determinista y consistente sirve, porque su arbitrariedad no añade injusticia: solo la vuelve reproducible. Lo importante es que todas las réplicas apliquen el mismo, y por eso el identificador tiene que viajar dentro del sello y no derivarse del contexto de quien fusiona.

Ahora el error que de verdad hace perder datos en implementaciones que ya usan reloj lógico, y que se comete casi siempre. El reloj tiene dos entradas: sellar, para escribir, y observar, para incorporar lo que llega. Si la réplica fusiona un estado remoto sin llamar a observar con su sello, su reloj no se entera de nada, y la siguiente escritura local se sella con un valor que puede ser menor que el del cambio que el usuario acaba de leer en pantalla. El resultado es una corrección que nace derrotada: el usuario ve el valor ajeno, escribe encima, ve su cambio aplicado en local y lo ve desaparecer en la siguiente sincronización, una y otra vez.

// INCORRECTO: el reloj local no ve el sello remoto
function aplicarRemotoMal(estado, remoto) {
  return fusionarLWW(estado, remoto);
}

// CORRECTO: fusionar es tambien observar
function aplicarRemoto(estado, remoto, reloj) {
  observar(reloj, remoto.sello);
  return fusionarLWW(estado, remoto);
}
⚠️
La regla: nada entra sin pasar por el reloj

Todo sello que cruce la frontera hacia dentro de una réplica tiene que alimentar el reloj antes de tocar el estado, incluidos los que llegan en lotes de sincronización inicial, los que se leen del almacenamiento local al arrancar y los que vienen de otra pestaña por el canal de difusión. Si un solo camino de entrada se salta ese paso, el síntoma no es un fallo sino una escritura que no se pega, y el usuario que la sufre no tiene forma de describirla.

flowchart TB
R[estado remoto con su sello] --> O[el reloj observa y adopta el maximo]
O --> F[la fusion se queda con el sello mayor]
F --> E[estado local convergido]
E --> A[siguiente asignacion local]
A --> S[el sello nuevo supera a todo lo visto]
S --> G[la correccion del usuario gana]
style O fill:#a6e3a1,color:#11111b
style G fill:#a6e3a1,color:#11111b

Qué pierde, exactamente

La construcción es correcta y aun así destruye trabajo, y conviene ser preciso con el inventario porque la frase se pierde un cambio no transmite el alcance. Desaparece el valor perdedor completo. Desaparece la señal de que existió una alternativa, porque el estado resultante es indistinguible de uno en el que solo hubo una escritura. Y desaparece la posibilidad de recuperación, porque el valor descartado no queda en ningún sitio del que se pueda sacar.

Hay una pérdida adicional que depende solo de la granularidad y que sorprende a mucha gente: si el registro es el documento entero en lugar de cada campo, la escritura perdedora se lleva por delante campos que nadie disputaba. Dos personas editan una la fecha y otra el responsable, y quien llega con sello menor pierde también su edición aunque tocara un campo distinto. Bajar la granularidad a nivel de campo es la mejora más barata que existe en este territorio y la que más pérdida evita por línea escrita.

Y hay una pérdida que es propia del entorno local-first y que no aparece en un sistema conectado. Una escritura generada sin red se sella al escribirse y se envía tres días después, así que compite contra escrituras posteriores y pierde todas. Sellar en el momento del envío la haría ganar contra cambios que sí vieron el estado, lo cual es igual de incorrecto en la dirección contraria. Ninguna de las dos opciones es defendible, y esa ausencia de opción correcta es el diagnóstico del que sale la lección siguiente.

💡
Registrar lo descartado no obliga a cambiar de política

Puedes seguir eligiendo por sello y aun así anotar en un diario local qué valor perdió, con su sello y su réplica. El coste es una línea en la fusión y una tabla pequeña con poda por antigüedad, y a cambio obtienes tres cosas que hoy no tienes: una medida real de cuánto estás perdiendo, un botón de deshacer para el usuario que se queja, y un criterio empírico para decidir qué campos merecen la política de la lección siguiente. Nótese que anotar no es lo mismo que detectar: sin contexto causal sabrás que descartaste un valor, no si fue concurrente o simplemente viejo.

Las variantes que amortiguan el daño

Antes de saltar al registro multivalor conviene agotar lo que se puede hacer sin salir de esta política, porque tres de las cuatro mejoras son casi gratuitas y resuelven la mayor parte del dolor en un producto medio. La primera es la granularidad por campo. La segunda es el diario de descartes. La tercera es el sello monótono por réplica, que garantiza que dos escrituras del mismo dispositivo nunca compiten mal entre sí. La cuarta, y la única cara, es sustituir el registro por una estructura que conmute cuando el campo lo permita.

// Documento como mapa de registros independientes: cada campo compite por separado
function fusionarDocumento(a, b) {
  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] = fusionarLWW(a[clave], b[clave]);
  }
  return salida;
}

// Variante con diario: misma politica, pero la perdedora deja rastro
function fusionarConDiario(a, b, diario) {
  const gana = menor(a.sello, b.sello) ? b : a;
  const pierde = gana === a ? b : a;
  if (gana.valor !== pierde.valor) diario.push({ descartado: pierde, en: gana.sello });
  return gana;
}
🎯

Granularidad por campo

El registro es el campo, no el documento. Elimina de golpe toda la pérdida colateral de ediciones que ni siquiera se disputaban.

📓

Diario de descartes

Anota valor, sello y réplica de cada perdedora. Convierte una pérdida invisible en una métrica y en un deshacer.

🔒

Monotonía por réplica

El reloj híbrido garantiza que tus propias escrituras nunca se pisen hacia atrás, ni aunque el usuario cambie la hora del sistema.

🚪

Salida al multivalor

Cuando el diario demuestre que un campo concreto descarta escrituras que a alguien le importaban, ese campo ya tiene su respuesta.

El sello no mide el tiempo: elige un ganador y le pone una excusa cronológica

Lo que hay que interiorizar de esta construcción es que la marca de tiempo no está resolviendo el conflicto; lo está ocultando detrás de una metáfora que todos entendemos y que por eso nadie audita. Cuando dos escrituras son concurrentes, por definición ninguna sucedió antes que la otra en ningún sentido que le importe al sistema, y el sello no está descubriendo un orden preexistente: lo está inventando. Que la invención use números que se parecen a horas es lo que la hace pasar desapercibida, porque el resultado siempre admite una explicación plausible —ganó el más reciente— que ninguna persona razonable cuestiona en una revisión de código. Si en lugar del sello el sistema desempatara por el orden alfabético del identificador de dispositivo, la política sería exactamente igual de convergente, exactamente igual de destructiva y muchísimo más fácil de discutir, porque nadie defendería en voz alta que el portátil cuyo nombre empieza por A tiene más derecho a escribir que el resto. La marca de tiempo es esa misma arbitrariedad vestida de causalidad. De ahí sale la manera correcta de usar el registro de última escritura, que no es evitarlo —es una pieza excelente y en muchos campos es la respuesta óptima— sino usarlo sabiendo qué papel juega cada mitad. El reloj lógico compra dos propiedades reales y valiosas: que la corrección de un usuario que acaba de leer un valor gane siempre a ese valor, porque hay dependencia causal y el híbrido la respeta; y que el resultado sea reproducible e independiente de la calibración de los dispositivos. Eso es mucho, y es exactamente lo que la hora del sistema no da. Lo que el reloj no compra, ni puede, es legitimidad para el caso concurrente, porque en ese caso no hay ninguna verdad cronológica que descubrir y el sello se limita a repartir. Por eso las dos preguntas que hay que hacerle a cualquier implementación son independientes y hay que hacerlas las dos. La primera es de ingeniería: de dónde sale el sello, y si todos los caminos de entrada alimentan el reloj. La segunda es de producto: cuando este campo concreto reparta arbitrariamente, qué pierde la persona que quedó del lado equivocado del reparto. La primera tiene una respuesta técnica correcta y la has visto en esta lección. La segunda no la responde el reloj, la responde el criterio de la lección cuatro, y si nadie la responde la respuesta por defecto es que la persona pierde su trabajo y no se entera.

⚔️ Construye el registro y rómpelo
  1. Implementa el registro completo sobre el reloj híbrido, con desempate por identidad y una función de hora inyectable para poder mentirle.
  2. Pásale el verificador de semirretículo de la lección anterior con al menos cien sellos generados al azar, incluidos empates de par físico y lógico.
  3. Reproduce el error de no llamar a observar al recibir y mide cuántas correcciones consecutivas del usuario se pierden antes de que su reloj alcance al remoto.
  4. Compara la granularidad por documento y por campo sobre la misma traza de edición y cuenta cuántas escrituras salva la segunda.
  5. Añade el diario de descartes y déjalo corriendo una semana sobre uso real; ordena los campos por número de valores destruidos.
  6. Adelanta el reloj de una réplica un año y comprueba que el registro sigue convergiendo mientras deja de ser útil.