Por qué un número no vale: el ejemplo mínimo de todo el campo
Si dos réplicas incrementan a la vez y cada una envía el resultado, la fusión tiene que elegir y una de las dos operaciones desaparece sin dejar rastro: el contador roto es el experimento que explica el campo entero.
Hay un experimento que cabe en seis líneas y que contiene, en miniatura, todo lo que hace difícil la replicación sin autoridad central. Dos dispositivos guardan el número 42. Cada uno, sin conexión, incrementa el suyo. Cuando vuelven a verse, ambos traen el número 43, y no existe ninguna función que reciba esos dos cuarenta y tres y devuelva 44, porque la información que distinguía un incremento de dos incrementos se destruyó en el instante en que cada réplica escribió el resultado en lugar del hecho. La lección no trata de contadores: trata de por qué un tipo de dato aparentemente inofensivo resulta ser irreplicable, de qué propiedad exacta le falta, y de por qué las dos salidas que a todo el mundo se le ocurren primero —sumar en vez de elegir, enviar el incremento en vez del resultado— fracasan por motivos distintos y ambos instructivos. Al final de estas quince páginas tendrás el diagnóstico completo; las cuatro lecciones siguientes son la cura.
- Reproducir la pérdida de actualización con la implementación ingenua y saber señalar el instante exacto en que se destruye la información.
- Demostrar que ninguna función de fusión sobre un entero puede a la vez converger y preservar las dos operaciones.
- Entender por qué enviar el incremento en lugar del estado traslada el problema a la capa de transporte en vez de resolverlo.
- Extraer del ejemplo la forma general que tendrán todas las estructuras convergentes del resto del track.
El experimento: dos incrementos, un solo resultado
El montaje no puede ser más pobre y por eso es tan útil. Una réplica es un objeto con un identificador y un número; incrementar consiste en sumar uno; sincronizar consiste en que cada una reciba el estado de la otra y produzca un estado común. Todo el drama ocurre en esa última función, y conviene mirarla sin piedad porque la mayoría de los sistemas de sincronización caseros contienen alguna variante suya.
// Replicacion ingenua: cada replica guarda un numero y envia el resultado
function crearReplica(id, valor = 0) {
return { id, valor };
}
function incrementar(r) {
return { ...r, valor: r.valor + 1 };
}
// La unica fusion honesta sobre un entero suelto: elegir uno de los dos
function fusionarPorMaximo(a, b) {
return { ...a, valor: Math.max(a.valor, b.valor) };
}
let ana = crearReplica("ana", 42);
let ben = crearReplica("ben", 42);
ana = incrementar(ana); // 43, sin conexion, en el metro
ben = incrementar(ben); // 43, sin conexion, en otro pais
fusionarPorMaximo(ana, ben).valor; // 43 despues de dos incrementos
Sustituir Math.max por gana la última escritura no cambia absolutamente nada: cualquier criterio que elija entre 43 y 43 devuelve 43. Y ese es el punto que conviene fijar antes de seguir, porque suele malinterpretarse: el fallo no está en el criterio de desempate. No hay marca de tiempo, identificador de réplica ni reloj híbrido que arregle esto, porque el problema no es que estemos eligiendo mal al ganador, sino que la respuesta correcta —44— no es ninguno de los dos candidatos. Elegir es la operación equivocada, y ninguna mejora en cómo se elige la convierte en la correcta.
flowchart TD E[estado inicial 42 en las dos replicas] --> A[Ana incrementa y su copia vale 43] E --> B[Ben incrementa y su copia vale 43] A --> F[fusionar dos estados que valen 43] B --> F F --> R[resultado 43 tras dos incrementos] R --> P[el numero no distingue un incremento de dos] style A fill:#89b4fa,color:#11111b style B fill:#89b4fa,color:#11111b style R fill:#f38ba8,color:#11111b style P fill:#f38ba8,color:#11111b
El instante en que se pierde la información es identificable con precisión quirúrgica, y no es el de la fusión: es el de la escritura. Cuando Ana calcula 42 + 1 y guarda 43, está proyectando una operación sobre un estado y quedándose solo con el resultado. Ese 43 es una suma de dos sumandos, pero el tipo Number no tiene sitio donde anotar de dónde vino cada uno. La fusión llega media hora después a un escenario del que ya se han retirado las pruebas, y se le pide que reconstruya un crimen del que solo queda la posición final de los muebles.
No es un fallo de implementación, es una imposibilidad del tipo
Conviene elevar la observación anterior a un enunciado que se pueda comprobar, porque de ahí sale todo lo demás. Para que un sistema replique estados sin coordinación, su función de fusión debe cumplir tres leyes. Debe ser conmutativa, porque el orden en que llegan los mensajes depende de la red. Debe ser asociativa, porque los mensajes se agrupan de formas distintas en cada réplica. Y debe ser idempotente, porque cualquier transporte real reenvía, reintenta y reentrega, y fusionar dos veces el mismo estado no puede alterar el resultado.
// Las tres leyes que debe cumplir cualquier fusion sobre estados
const leyes = {
conmutativa: (f, a, b) => f(a, b) === f(b, a),
asociativa: (f, a, b, c) => f(f(a, b), c) === f(a, f(b, c)),
idempotente: (f, a) => f(a, a) === a,
};
const max = (a, b) => Math.max(a, b);
const suma = (a, b) => a + b;
leyes.idempotente(max, 43); // true -> converge, y 43 con 43 da 43
leyes.idempotente(suma, 43); // false -> preserva, y reenviar duplica
Ese par de comprobaciones es el teorema entero en dos líneas. Sobre los números naturales, la única familia de funciones que cumple las tres leyes es la de los máximos respecto de algún orden total, y toda función de ese tipo tiene una consecuencia inevitable: cuando los dos argumentos son iguales devuelve ese mismo valor, de modo que dos operaciones indistinguibles cuentan por una. La suma, que sí preservaría las dos operaciones, falla la tercera ley de manera catastrófica: cada reentrega infla el resultado, y como el estado no lleva ninguna marca de qué mensajes ya se aplicaron, ni siquiera se puede detectar que ha ocurrido.
Es tentador razonar que si el transporte garantiza entrega exactamente una vez, la suma vuelve a ser válida. El razonamiento es correcto y el supuesto es falso. La entrega exactamente una vez no existe como propiedad de red: se construye por encima, y se construye exactamente con lo que aquí falta, es decir, con identificadores por mensaje y un registro de lo ya recibido que hay que guardar, replicar y podar. Al exigir un transporte de esa clase no has eliminado el problema, lo has movido a otra capa donde vuelve a aparecer con el mismo nombre y con peor sitio para resolverlo. Y en local-first el supuesto es especialmente frágil, porque las réplicas se apagan a medias, restauran copias de seguridad antiguas y reinstalan la aplicación, tres eventos que rompen cualquier contabilidad de entrega que viva solo en memoria o solo en un extremo.
Las salidas que parecen salidas
La primera reacción de cualquier equipo ante el experimento anterior es enviar el incremento y no el resultado. La idea es buena y es, de hecho, la semilla de toda la familia de estructuras basadas en operaciones. Pero tal como se implementa sin más, hereda intacto el problema de la idempotencia y lo hace de una forma que en producción se manifiesta como un contador que crece solo.
// Enviar el hecho en vez del resultado: correcto solo si cada mensaje
// se aplica exactamente una vez, que es justo lo que la red no ofrece
function aplicarDelta(r, delta) {
return { ...r, valor: r.valor + delta.cantidad };
}
let cen = crearReplica("cen", 42);
const mensaje = { de: "ana", cantidad: 1 };
cen = aplicarDelta(cen, mensaje); // 43
cen = aplicarDelta(cen, mensaje); // 44 tras un reintento del transporte
La segunda salida habitual es devolver la aritmética al servidor: que el cliente no envíe ni el estado ni el delta, sino la intención, y que una sentencia del estilo UPDATE tabla SET n = n + 1 resuelva el orden en un único punto. Funciona, y conviene decirlo sin rodeos porque es la respuesta correcta para muchísimos sistemas. Lo que hay que ver es qué se ha comprado: se ha comprado un árbitro, y con él la latencia de red en el camino crítico de cada escritura, la indisponibilidad cuando el árbitro no está y el requisito de que el usuario tenga conexión para que su acción cuente. Es decir, se han cancelado los tres primeros ideales de local-first para arreglar un contador.
Elegir un ganador
Converge siempre y no requiere metadatos. A cambio, dos operaciones concurrentes se cuentan como una y no queda ni rastro de la perdida.
Sumar los estados
Preserva las dos operaciones y falla la idempotencia. Cada reentrega infla el total y no hay forma de detectar el error desde dentro.
Enviar el incremento
Correcto bajo entrega exactamente una vez, que hay que construir con la contabilidad que precisamente se quería evitar.
Contar en el servidor
Solución legítima y a menudo la mejor, al precio de reintroducir el árbitro central y perder la escritura sin conexión.
Lo que este ejemplo enseña del resto del campo
Puestas las cuatro opciones sobre la mesa, el diagnóstico se puede formular de una manera que resulta sorprendentemente general. El estado que enviamos —un número— es una proyección con pérdida de la historia que lo produjo, y estamos pidiéndole a la fusión que invierta esa proyección. Ninguna función puede hacerlo, porque la información simplemente no está. La conclusión no es que los contadores sean irreplicables, sino que hay que replicar un estado del que la fusión sí pueda deducir la respuesta, y eso obliga a preguntarse cuál es la estructura mínima que conserva lo justo.
La respuesta que da la próxima lección se ve venir en cuanto se plantea así la pregunta. Lo que un solo número no puede distinguir es quién incrementó, de modo que la reparación consiste en no sumar todavía: guardar la contribución de cada réplica por separado y dejar la suma para el momento de leer. Con eso, dos incrementos de dos réplicas distintas ocupan casillas distintas, la fusión ya no tiene que elegir entre ellos porque no compiten, y el máximo elemento a elemento —idempotente, conmutativo y asociativo— resulta ser exactamente la operación correcta.
Merece la pena detenerse en la forma exacta del arreglo que acabamos de anunciar, porque no es un truco para contadores: es el movimiento, repetido con variaciones, de las quince lecciones de estructuras convergentes que vienen detrás. Observa qué hace realmente la reparación. No cambia la operación de fusión buscando una más lista; no añade relojes, ni marcas, ni árbitros; no toca el transporte. Lo único que hace es retrasar el momento en que se aplica la función que pierde información. La suma sigue estando ahí y sigue perdiendo exactamente lo mismo que perdía antes, pero ahora se ejecuta al leer, sobre un estado completo, en lugar de al escribir, sobre un estado parcial. Y como leer no tiene que replicarse, la pérdida deja de importar. Esa es la asimetría fundamental que conviene interiorizar: una función con pérdida es inofensiva después de la fusión y es fatal antes, porque antes de la fusión destruye precisamente las diferencias que la fusión necesitaba comparar. De ahí sale una definición operativa de qué es un tipo de dato replicado convergente que resulta mucho más útil que la habitual: no es una estructura de datos con una fusión mágica, es un par formado por un estado que solo crece y una consulta que lo interpreta. El estado vive en un orden parcial donde toda escritura sube y nunca baja, de modo que la fusión puede ser siempre la misma operación —tomar la cota superior mínima de dos estados— sin saber nada del dominio; y toda la semántica del tipo, todo lo que lo hace un contador y no un conjunto o un texto, está en la consulta. Cambia la consulta dejando el estado intacto y obtienes otro tipo de dato: lo verás literalmente en la lección siguiente, donde el mismo mapa de réplicas a números es un contador si lo sumas y es el reloj vectorial del nivel veintidós si lo comparas. Esta lectura explica además por qué el campo se organiza como se organiza. Si la fusión es siempre la misma y la dificultad está en encontrar un estado monótono cuya consulta reproduzca la semántica que quieres, entonces catalogar CRDT es catalogar descomposiciones monótonas de operaciones que no lo son, y la investigación consiste en encontrarlas para operaciones cada vez menos hospitalarias: restar, que rompe la monotonía y ocupa la lección tres; borrar, que la rompe peor y obliga a las lápidas; insertar en una posición de un texto, que ni siquiera tiene una noción obvia de estado que crezca. También explica por qué existen límites duros y no solo problemas difíciles: si la semántica que quieres exige una restricción sobre el resultado de la consulta —que el contador no baje de cero, por ejemplo—, esa restricción no se puede trasladar hacia atrás al estado, porque la consulta va en un solo sentido y la fusión no la conoce. Eso no es una carencia de las implementaciones actuales: es una barrera, y la lección cuatro la demuestra con un ejemplo que cualquiera reconocerá de su trabajo. Por eso este ejemplo de seis líneas es el mejor sitio para empezar quince niveles de material: contiene el diagnóstico, contiene la forma de la cura y contiene el límite de la cura, y lo hace con un tipo de dato que cabe en un entero.
- Implementa las tres funciones del primer bloque y comprueba con tus manos que dos incrementos concurrentes producen 43.
- Sustituye
Math.maxpor un criterio de última escritura con marca de tiempo y verifica que el resultado no mejora en ningún caso. - Escribe las tres leyes como comprobaciones ejecutables y pásalas a cinco funciones de fusión distintas que se te ocurran.
- Implementa la variante que envía deltas y simula un transporte que reentrega el diez por ciento de los mensajes; mide la deriva tras mil operaciones.
- Añade a esa variante un registro de identificadores ya aplicados y anota cuánta memoria consume y cuándo podrías borrarlo.
- Busca en tu aplicación actual todo campo numérico que se actualice leyendo, sumando y guardando, y clasifica cada uno según cuál de las cuatro opciones está usando sin saberlo.