wandres.dev
RELOJES II · vectores y relojes híbridos

Version vectors: versionar réplicas, no eventos

El version vector se parece al vector clock hasta que miras la regla de incremento, y esa única diferencia decide si tu sistema resume estados de réplica o etiqueta eventos individuales.

⏱ 17 min

Hay dos estructuras en esta materia que se escriben igual, se comparan igual y se fusionan igual, y que sin embargo responden a preguntas distintas. Ambas son mapas de identificador de réplica a contador; ambas usan el máximo puntual como fusión; ambas producen un orden parcial con concurrencia. La literatura las llama vector clock y version vector, mucha gente las usa como sinónimos y la mayoría de los sistemas que confunden una con otra funcionan durante meses antes de empezar a inventarse conflictos que nunca ocurrieron. La diferencia cabe en una línea de código —qué eventos incrementan tu propia coordenada— y de esa línea se sigue todo lo demás: dónde vive la estructura, cuántas copias hay, qué significa compararlas y para qué sirve el resultado.

🎯 Al terminar esta lección sabrás
  • Enunciar con precisión qué versiona un version vector y qué ordena un vector clock.
  • Aislar la regla de incremento que las separa y razonar qué se rompe al intercambiarlas.
  • Usar el version vector como resumen de lo que una réplica ya posee, y con él calcular deltas.
  • Reconocer el límite del version vector y por qué existen los dotted version vectors.

Dos objetos que se escriben igual

El vector clock de la lección anterior es una etiqueta de evento. Hay un vector por cada evento del sistema, ese vector viaja pegado al evento, se guarda con él si lo guardas y su misión es situar ese evento concreto en el orden causal frente a cualquier otro. El version vector es otra cosa: es un resumen del estado de una réplica o de un objeto replicado. Hay uno solo por réplica, o uno por objeto y réplica, y su misión no es ordenar eventos sino contestar a la pregunta de qué actualizaciones ha visto ya esta réplica y cuántas ha originado.

La formulación que popularizaron Baquero y Preguiça al ordenar este zoo conceptual es la más útil de recordar: los vector clocks establecen causalidad entre eventos y los version vectors establecen causalidad entre estados de réplica. Ambos usan el mismo tipo de dato porque el problema tiene la misma forma —contar por origen— pero el objeto contado difiere, y con él la regla de mantenimiento.

La confusión terminológica tiene además una raíz histórica que ayuda a no tomársela como un descuido de nadie. Los version vectors aparecieron primero, en los años ochenta, para reconciliar réplicas de ficheros en sistemas distribuidos de almacenamiento; los vector clocks nacieron poco después en el contexto del razonamiento sobre trazas de ejecución y depuración de sistemas distribuidos. Son linajes distintos que convergieron en la misma estructura de datos y heredaron nombres que ya no se pueden cambiar. Cuando leas documentación de una biblioteca, comprueba siempre la regla de incremento antes de fiarte del nombre que use.

De esa diferencia sale una asimetría de cardinalidad que conviene tener presente desde el principio. Los vector clocks son tantos como eventos: si guardas cien mil operaciones tienes cien mil vectores, cada uno de tamaño proporcional al número de réplicas. Los version vectors son tantos como réplicas por objeto: uno solo, que se sobreescribe. El primero es historia; el segundo es resumen. Y un resumen no puede reconstruir la historia, igual que un saldo bancario no puede reconstruir los movimientos.

flowchart TB
subgraph VC[Vector clock]
  E1[evento 1 con su sello] --> E2[evento 2 con su sello]
  E2 --> E3[evento 3 con su sello]
end
subgraph VV[Version vector]
  S[un unico resumen por replica]
  S --> S2[se sobreescribe con el maximo puntual]
end
VC --> P1[ordena eventos entre si]
VV --> P2[compara estados de replica]
style VC fill:#89b4fa,color:#11111b
style VV fill:#cba6f7,color:#11111b

La regla que las separa

El vector clock incrementa la coordenada propia en todo evento local, y la recepción de un mensaje cuenta como evento local. El version vector incrementa la coordenada propia solo cuando la réplica origina una actualización del objeto; recibir y fusionar no incrementa nada, porque recibir no crea una versión nueva del objeto, solo te pone al día sobre versiones que ya existían.

// Version vector de un objeto replicado
function actualizarLocal(vv, idPropio) {
  vv[idPropio] = (vv[idPropio] ?? 0) + 1; // solo las escrituras propias cuentan
  return vv;
}

function fusionar(vvLocal, vvRemoto) {
  for (const id of Object.keys(vvRemoto)) {
    vvLocal[id] = Math.max(vvLocal[id] ?? 0, vvRemoto[id]);
  }
  return vvLocal; // sin incremento: recibir no origina una version nueva
}

Esa línea que no está ahí es toda la lección. Piensa en dos réplicas que han acabado con exactamente los mismos datos tras intercambiarse todo lo que tenían. Con semántica de version vector, sus vectores acaban siendo idénticos, la comparación devuelve igualdad y el sistema concluye correctamente que están sincronizadas. Con semántica de vector clock aplicada al mismo caso, cada una incrementó su coordenada al recibir del otro, los vectores quedan incomparables y el sistema concluye que hay un conflicto entre dos estados que son literalmente el mismo dato. Es el fallo más común de esta materia y es especialmente insidioso porque no rompe nada de golpe: solo produce un goteo creciente de conflictos falsos que el equipo achaca al usuario, a la red o al azar.

⚠️
El error de simetría inversa

Usar semántica de version vector para ordenar eventos también falla, aunque de forma menos visible. Si no incrementas al recibir, dos eventos producidos en réplicas distintas después de fusiones distintas pueden acabar con el mismo sello, y el sistema pierde la capacidad de decir cuál de dos operaciones de la misma réplica fue antes. Cada estructura resuelve su problema; ninguna de las dos resuelve el de la otra por accidente.

El resumen que habilita el delta

Una vez que aceptas que el version vector es un resumen y no una historia, aparece su uso más rentable y el que probablemente justifica su existencia en un sistema local-first: es la forma canónica de decir lo que ya tengo. Una réplica que quiere sincronizar envía su version vector; la otra lo compara con el suyo y calcula exactamente qué operaciones tiene que mandar, sin transferir nada más y sin necesidad de que ninguna de las dos recuerde el estado de la conversación anterior.

// La replica B recibe el resumen de A y calcula que le falta
function calcularDelta(operaciones, vvRemoto) {
  return operaciones.filter((op) => op.contador > (vvRemoto[op.replica] ?? 0));
}

// Un intercambio completo son dos resumenes y dos deltas
function sincronizar(a, b) {
  const paraB = calcularDelta(a.log, b.vv);
  const paraA = calcularDelta(b.log, a.vv);
  return { paraA, paraB };
}

Con el resumen en la mano, la comparación de dos estados de réplica se lee de forma casi narrativa y conviene tener la traducción a mano, porque cada uno de los cuatro veredictos activa una rama distinta del protocolo.

function decidirSincronizacion(vvLocal, vvRemoto) {
  switch (comparar(vvLocal, vvRemoto)) {
    case "igual":
      return "nada que hacer: los dos estados coinciden";
    case "antes":
      return "el remoto me domina: solo recibo";
    case "despues":
      return "yo lo domino: solo envio";
    default:
      return "divergencia real: intercambio en ambas direcciones y fusion";
  }
}

Los tres primeros casos son transferencia pura y no requieren ninguna decisión de dominio. El cuarto es el interesante: significa que ambas réplicas tienen algo que la otra no tiene, y es el único que puede acabar necesitando una política de resolución. Que el protocolo distinga los cuatro no es cosmético; es lo que evita ejecutar la maquinaria de fusión en el noventa y tantos por ciento de los encuentros, donde no hay nada que fusionar.

La propiedad que hace esto correcto es que el version vector es un resumen sin huecos: la coordenada de la réplica r con valor k significa que tienes todas las actualizaciones de r de la uno a la k, no algunas de ellas. Esa contigüidad es una invariante que hay que defender, porque el día que apliques una actualización fuera de orden y avances el contador, el resumen empieza a mentir y todas las sincronizaciones posteriores omitirán datos que creen ya entregados. Aplicar en orden por réplica, o encolar hasta poder hacerlo, no es una optimización: es la condición de validez de la estructura.

💡
Un resumen es un protocolo sin estado

El intercambio de version vectors convierte la sincronización en una conversación sin memoria: da igual cuándo hablaron por última vez, cuántas veces se cortó la conexión o si un tercero les entregó datos por otro camino. Cada encuentro empieza mostrando el resumen y termina intercambiando la diferencia. Para un sistema local-first, donde los encuentros son irregulares y el transporte puede ser cualquier cosa, esa ausencia de sesión vale mucho más de lo que cuesta el vector.

Dónde el resumen se queda corto

El version vector tiene un punto ciego preciso y muy conocido, y aparece cuando varios escritores comparten una misma identidad de réplica. Es la situación normal en las arquitecturas donde muchos clientes escriben a través de un mismo nodo servidor: el nodo incrementa su única coordenada por cada escritura, y si dos clientes escribieron concurrentemente basándose en la misma versión previa, el resumen resultante no conserva la información necesaria para saber qué valor produjo cada escritura. El sistema o bien descarta un valor que debía conservarse como hermano, o bien conserva hermanos que en realidad se dominaban entre sí, y el conjunto de hermanos crece sin control.

🧷

El punto de la versión

Un dotted version vector añade al resumen un par identificador y contador que señala el evento exacto que produjo ese valor concreto. Resumen más punto, no una cosa o la otra.

🧬

Hermanos con precisión

Con el punto, dos valores producidos por escrituras distintas bajo la misma réplica quedan distinguidos y la relación de dominación entre ellos vuelve a ser exacta.

📉

Conjuntos que no crecen

La consecuencia práctica es que el número de valores concurrentes conservados deja de inflarse con el tráfico y pasa a estar acotado por la concurrencia real.

🏭

No es teoría

Preguiça y Baquero formalizaron la construcción en 2010 y sistemas de producción como Riak la adoptaron precisamente para arreglar la explosión de hermanos que sufrían.

La forma del arreglo merece entenderse aunque no vayas a implementarla, porque el patrón se repite en otras partes de esta materia. Un valor deja de llevar solo un resumen y pasa a llevar un resumen más un punto: el resumen dice todo lo que el autor había visto y el punto identifica la escritura concreta que lo produjo, con su identidad de réplica y su número. Con esas dos piezas la dominación entre valores vuelve a ser exacta, porque un valor domina a otro cuando su resumen ya contiene el punto del otro, y eso se comprueba mirando una sola coordenada.

📝
Resumen más punto, un patrón que reaparece

La misma idea sostiene los conjuntos de tipo añadir gana en su versión optimizada: en vez de guardar una etiqueta única por cada elemento vivo, se guarda un contexto causal —un version vector— y los puntos sueltos que todavía no han quedado cubiertos por él. El resumen absorbe la mayoría de las etiquetas y el metadato deja de crecer con el número de operaciones para crecer solo con la concurrencia pendiente.

La lección de diseño que hay detrás vale más que la estructura concreta: la identidad de réplica es una decisión de arquitectura y no un detalle administrativo. Si dos escritores independientes comparten identidad, ninguna estructura basada en contar por origen puede separarlos, porque el origen es justo lo que has decidido no distinguir. Y la reacción tentadora de dar identidad propia a cada cliente resuelve el punto ciego a cambio de multiplicar el número de réplicas, que es exactamente el coste que estudia la lección siguiente.

El resumen y la historia son dos compresiones distintas del mismo grafo

Merece la pena subir un peldaño y ver las dos estructuras como lo que realmente son: dos formas de comprimir el mismo objeto, el grafo causal de todo lo que ha pasado en el sistema. La historia con sellos por evento conserva la posición de cada nodo del grafo y paga un vector por nodo; el resumen conserva únicamente la frontera de lo conocido y paga un vector por réplica, olvidando por completo el interior. La elección entre ambas no es una cuestión de gusto ni de tradición de una biblioteca: es una elección sobre qué preguntas quieres poder responder mañana. Con el resumen puedes contestar a qué me falta y a si estos dos estados son comparables, que son las dos preguntas del protocolo de sincronización, y son suficientes para mover datos entre dispositivos con eficiencia y sin sesión. Con la historia puedes contestar además a por qué el documento acabó así, quién sabía qué en cada momento y en qué punto exacto divergieron dos versiones, que son las preguntas de la auditoría, del deshacer selectivo y de la fusión estructural. Casi todos los sistemas serios acaban necesitando las dos y llevan las dos, con nombres que no ayudan y en capas distintas del código; el error no es llevar ambas, el error es creer que son la misma y aplicarle a una las reglas de la otra. Y hay un corolario incómodo que conviene aceptar pronto: como el resumen no reconstruye la historia, el día que descubras que necesitabas la historia no habrá forma de recuperarla, porque los eventos que la contenían ya se descartaron al fusionar. Esa es una decisión que se toma el primer día del proyecto, aunque nadie recuerde haberla tomado.

⚔️ Separa el resumen de la historia
  1. Implementa las dos estructuras en el mismo módulo con nombres distintos y prohíbete pasar una donde se espera la otra.
  2. Simula dos réplicas que se sincronizan por completo y comprueba que sus version vectors quedan iguales; repite incrementando al recibir y cuenta los conflictos falsos que aparecen.
  3. Escribe el cálculo de delta a partir de un resumen remoto y verifica que la unión de dos sincronizaciones parciales entrega el mismo resultado que una completa.
  4. Rompe la contigüidad a propósito aplicando una operación fuera de orden y avanzando el contador: mide cuántas operaciones se pierden en las sincronizaciones siguientes.
  5. Reproduce la explosión de hermanos con dos clientes bajo una misma identidad de réplica y arréglala añadiendo un punto a cada valor.