MV-Register: conservar los dos valores y decirlo
El registro multivalor renuncia a elegir por el usuario y devuelve el conjunto de valores escritos concurrentemente, a cambio de contexto causal y de una interfaz que tenga que saber mostrar un conflicto.
La segunda salida invierte la pregunta. En vez de fabricar un orden donde no lo hay, el registro multivalor acepta que dos escrituras pueden ser genuinamente incomparables y se limita a conservarlas las dos, dejando que la lectura devuelva un conjunto en lugar de un valor. La consecuencia es más grande de lo que parece y no es de rendimiento: el tipo de la lectura cambia, y ese cambio de tipo se propaga hacia arriba hasta la interfaz, que ya no puede pintar el campo sin haber decidido antes qué hace cuando hay dos. Esa incomodidad es la característica, no el defecto. Un sistema que devuelve un conjunto está diciendo la verdad sobre su estado; uno que devuelve un valor está afirmando que sabe cuál era el correcto, y en el caso concurrente esa afirmación es falsa. Todo lo demás de esta lección —vectores, dominación, poda— es la maquinaria que hace esa honestidad barata.
- Entender la lectura como una anticadena de valores mutuamente concurrentes, no como un valor único.
- Implementar el registro multivalor completo con vectores de versión del nivel veintidós.
- Distinguir con precisión qué crece —y qué no— en esta estructura, y por qué la explosión de hermanos es un fallo de cliente.
- Reconocer las tres maneras de neutralizar la construcción sin darse cuenta desde la interfaz.
La anticadena: la lectura deja de devolver un valor
El estado de un registro multivalor no es un valor con metadatos, sino un conjunto de pares formados por un valor y su contexto causal. La invariante que lo define es que ese conjunto es una anticadena: ningún elemento domina causalmente a otro. En cuanto una escritura domina a otra, la dominada deja de tener sentido y se descarta, porque quien escribió la nueva ya había visto la vieja y su intención era sustituirla. Solo sobreviven juntas las escrituras que se hicieron sin conocerse.
De ahí sale la propiedad que hace la estructura tolerable en la práctica y que casi nadie espera al conocerla: el conjunto no acumula historia. Cada asignación local colapsa a un único elemento todo lo que la réplica había visto hasta ese momento, así que el tamaño del conjunto no mide cuántas veces se ha editado el campo sino cuántas ramas de edición concurrente siguen abiertas ahora mismo. En una aplicación normal ese número es uno casi todo el tiempo, dos ocasionalmente y tres muy rara vez.
flowchart TB I[valor inicial visto por ambas replicas] --> A[replica A asigna rojo] I --> B[replica B asigna azul] A --> F[fusion en la que ninguno domina al otro] B --> F F --> S[lectura devuelve rojo y azul] S --> C[alguien asigna verde tras verlos] C --> U[la anticadena vuelve a tener un solo elemento] style F fill:#f9e2af,color:#11111b style U fill:#a6e3a1,color:#11111b
La implementación
Todo se apoya en el vector de versiones del nivel veintidós y en su relación de dominación. Conviene escribir primero esas dos piezas por separado, porque son las que deciden la corrección y las que hay que probar a fondo.
// Vector de versiones: un contador por replica, ausente equivale a cero
function unionVV(a, b) {
const r = { ...a };
for (const k of Object.keys(b)) r[k] = Math.max(r[k] ?? 0, b[k]);
return r;
}
function dominaOigual(a, b) {
return Object.keys(b).every((k) => (a[k] ?? 0) >= b[k]);
}
const dominaEstricto = (a, b) => dominaOigual(a, b) && !dominaOigual(b, a);
const concurrentes = (a, b) => !dominaOigual(a, b) && !dominaOigual(b, a);
El registro es una lista de entradas, cada una con su valor y su vector. La asignación es la operación interesante: no añade una entrada, las sustituye a todas, porque quien escribe ha visto todo lo que su réplica tenía y su vector domina a todos los que había.
function crearMV() {
return { entradas: [] };
}
function asignarMV(reg, replica, valor) {
const base = reg.entradas.reduce((acc, e) => unionVV(acc, e.vv), {});
const vv = { ...base, [replica]: (base[replica] ?? 0) + 1 };
return { entradas: [{ valor, vv }] }; // colapsa toda la anticadena vista
}
function fusionarMV(a, b) {
const todas = [...a.entradas, ...b.entradas];
const vivas = todas.filter((e) => !todas.some((o) => dominaEstricto(o.vv, e.vv)));
const unicas = new Map();
for (const e of vivas) unicas.set(JSON.stringify(e.vv), e);
return { entradas: [...unicas.values()] };
}
La deduplicación por vector no es una optimización, es lo que hace idempotente la fusión: dos copias de la misma entrada tienen vectores idénticos, ninguna domina estrictamente a la otra y ambas sobrevivirían al filtro. Con el mapa por vector serializado, fusionar un estado consigo mismo devuelve exactamente el mismo estado, que es la tercera ley del semirretículo.
La lectura merece dos cuidados que se olvidan siempre. El primero es el orden: la lista tiene que salir ordenada de forma determinista, porque si no dos réplicas convergentes pintarán el conflicto en distinto orden y el usuario creerá que no han convergido. El segundo es la deduplicación por valor: dos personas pueden escribir concurrentemente exactamente lo mismo, y en ese caso hay dos entradas y un solo valor, así que enseñar dos opciones idénticas es un conflicto inventado.
function leerMV(reg) {
const clave = (e) => JSON.stringify(e.vv);
const orden = [...reg.entradas].sort((x, y) => (clave(x) < clave(y) ? -1 : 1));
return [...new Set(orden.map((e) => e.valor))]; // determinista y sin duplicados
}
Que dos réplicas escriban el mismo texto a la vez es más frecuente de lo que sugiere la intuición: dos personas corrigiendo la misma errata evidente, dos dispositivos del mismo usuario aplicando el mismo cambio, un reintento que se duplica por el camino. Sin deduplicación por valor en la lectura, esos casos aparecen como un conflicto que pide arbitraje humano y cuya única resolución posible es elegir entre dos opciones idénticas. Es la vía más rápida para que un equipo concluya que el registro multivalor es inusable.
Lo que crece, y lo que no
Hay que separar dos crecimientos distintos que se confunden constantemente. El primero es el número de entradas, y ya vimos que está acotado por la anchura de la concurrencia actual, no por la historia: una sola asignación posterior a haberlas visto todas devuelve el conjunto a un elemento. El segundo es el tamaño de cada vector de versiones, que crece con el número de réplicas que han escrito alguna vez ese campo y que no se reduce solo. En un producto con muchos dispositivos por usuario, ese segundo coste es el que duele, y es exactamente el que el nivel veintidós ya te enseñó a pagar y a podar.
Existe además un modo de fallo con nombre propio, la explosión de hermanos, que se atribuye a la estructura y en realidad es siempre un fallo del cliente. Ocurre cuando alguien lee el registro, decide un valor y escribe sin devolver el contexto causal que leyó. Al no dominar a nada, su escritura se añade a la anticadena en vez de colapsarla, y el conjunto crece sin freno escritura tras escritura. Es el mismo error de la lección anterior con otra cara: allí se olvidaba alimentar el reloj al recibir, aquí se olvida devolver el vector al escribir.
// INCORRECTO: la resolucion no domina a lo que resolvio
function resolverMal(reg, replica, elegido) {
return { entradas: [{ valor: elegido, vv: { [replica]: 1 } }] };
}
// CORRECTO: resolver es asignar habiendo visto la anticadena entera
function resolver(reg, replica, elegido) {
return asignarMV(reg, replica, elegido); // el vector base domina a todos los hermanos
}
La tentación de implementar la resolución de conflicto quitando las entradas que el usuario descartó es fuerte y produce un sistema que no converge: otra réplica que no haya visto la resolución seguirá teniendo esas entradas y volverá a introducirlas en la siguiente fusión, porque nada las domina. La resolución tiene que ser una asignación normal cuyo vector base sea la unión de todos los hermanos. Formulado como regla: en esta estructura no se elimina información, se domina.
Devolver un conjunto es una decisión de interfaz
El registro multivalor no resuelve el conflicto: lo hace representable. Quien lo resuelve es la capa de arriba, y ahí es donde la construcción se gana o se pierde por completo. Hay tres maneras de neutralizarla sin darse cuenta, y las tres son habituales. La primera es que la interfaz tome siempre el primer elemento de la lista: eso es un registro de última escritura con más metadatos, más latencia y ninguna ventaja. La segunda es tomar el mayor por sello si además se guardó uno: lo mismo, con la excusa cronológica de vuelta. La tercera, más sutil, es mostrar el conflicto en un lugar que nadie mira, con lo cual el usuario resuelve sin saberlo la próxima vez que edite el campo por otro motivo.
La alternativa exige poco código y bastante criterio de producto. Para un campo enumerado, mostrar las dos opciones y pedir que se elija es barato y honesto. Para dos textos, ofrecer los dos y permitir quedarse con uno o combinarlos a mano funciona. Para valores numéricos con semántica de acumulación, muchas veces la respuesta correcta no es elegir sino aplicar una función de resolución determinista —el máximo, la suma— y en ese momento lo que estás descubriendo es que ese campo no era un registro.
Hermanos visibles
Si el conflicto no aparece donde el usuario está mirando, no existe. Un aviso en un panel de administración es equivalente a no haberlo detectado.
Resolución automática declarada
Máximo, suma, concatenación ordenada. Válida si está escrita en el esquema y es determinista, no si la improvisa la interfaz al pintar.
Contexto causal de ida y vuelta
Lo que se lee se devuelve al escribir. Sin eso la anticadena no colapsa nunca y la estructura parece defectuosa cuando el defectuoso es el cliente.
Mide la anchura
Publica como métrica el número de hermanos por campo. Si un campo pasa de dos con frecuencia, o hay explosión o ese campo necesita otra estructura.
El valor real de esta construcción no está en la anticadena ni en los vectores, que son maquinaria conocida desde los años ochenta, sino en un efecto que ocurre en una capa donde no se suele buscar: al cambiar el tipo de la lectura, obliga a todo el programa a admitir que el conflicto existe. Mientras el campo devuelve un valor, la aplicación entera está escrita bajo la premisa de que en cada momento hay un texto, un estado, una fecha, y esa premisa se compila, pasa las pruebas y se despliega sin que nadie la haya afirmado nunca en voz alta. La premisa es falsa en un sistema replicado, y su falsedad no se manifiesta como un error sino como una pérdida silenciosa, precisamente porque el tipo no dejaba sitio donde ponerla. Cuando la lectura devuelve un conjunto, esa mentira deja de ser expresable: cada punto del código que pinta el campo tiene que decidir explícitamente qué hace con dos, y esa decisión, tomada cuarenta veces en cuarenta sitios distintos, es el verdadero producto de la estructura. Se puede leer como una aplicación del principio de hacer irrepresentables los estados inválidos, con la particularidad de que aquí el estado inválido no es un dato corrupto sino una afirmación epistemológica infundada sobre el mundo. De ahí salen dos consecuencias que conviene tener claras antes de adoptarlo. La primera es que el registro multivalor no es más correcto que el de última escritura en ningún sentido formal: los dos convergen, los dos son semirretículos y ninguno de los dos sabe cuál era el valor que el usuario quería. La diferencia entera está en dónde se toma la decisión y quién puede verla, y por eso la elección entre ambos no es un problema de teoría de retículos sino de diseño de producto, que es justamente lo que resuelve la lección siguiente. La segunda es que su coste principal tampoco es el que se anuncia. Los vectores son caros pero acotados y ya sabes podarlos; lo caro es que cada campo multivalor obliga a diseñar, implementar y probar una experiencia de resolución, y ese trabajo no es reutilizable entre campos porque depende del significado de cada uno. Un equipo que declara multivalor la mitad de su modelo no ha comprado seguridad: ha comprado cuarenta pantallas de conflicto que nadie va a diseñar, y que terminarán resolviéndose con el primer elemento de la lista, que es donde empezamos. La estructura es excelente y es cara en el sitio donde nadie mira el presupuesto, y usarla bien consiste en gastarla en los pocos campos donde perder de verdad duele.
- Implementa el registro multivalor completo y pásale el verificador de semirretículo con estados generados por trazas aleatorias de tres réplicas.
- Construye un caso con tres escrituras mutuamente concurrentes y comprueba que la lectura devuelve tres valores y que una cuarta asignación las colapsa.
- Reproduce la explosión de hermanos escribiendo sin devolver el contexto causal y mide cuántas escrituras hacen falta para llegar a diez.
- Añade la deduplicación por valor y cuenta cuántos conflictos inventados desaparecen sobre una traza donde dos réplicas corrigen la misma errata.
- Implementa la resolución como dominación y verifica que una réplica desactualizada no reintroduce los hermanos descartados.
- Instrumenta la anchura media y máxima por campo durante una semana de uso real y decide con ese dato qué campos merecen esta estructura.