Los tipos que no encajan: contadores, referencias y bordes afilados
Contadores, referencias entre nodos y movimientos de subárboles son las tres cosas que el modelo JSON no sabe hacer bien, y conocer esos bordes antes de fijar el esquema evita migraciones que aquí salen carísimas.
El árbol de la lección anterior cubre una fracción sorprendentemente alta de lo que se necesita, y esa eficacia tiene un efecto secundario peligroso: invita a suponer que cubre todo. No lo hace, y los casos que se le resisten no son exóticos ni infrecuentes. Son un número que se quiere incrementar, una referencia de un nodo a otro y el gesto de arrastrar un elemento de un sitio a otro, es decir, tres cosas que aparecen en el tercer día de cualquier proyecto. Ninguna encaja de forma natural en el modelo, y las tres fallan de una manera característica: no dan error, no rompen la convergencia y producen un resultado perfectamente determinista que resulta ser el equivocado. Esta lección cierra el nivel recorriendo esos bordes uno a uno, no para desanimar el uso del modelo sino por la razón contraria: son mucho más baratos de esquivar en el momento de escribir el esquema que de reparar cuando ya hay documentos de usuarios en cientos de dispositivos que no puedes migrar a la vez.
- Diagnosticar por qué una hoja numérica pierde incrementos y qué estructura la sustituye.
- Modelar referencias entre nodos sabiendo qué garantías se pierden al salir del árbol.
- Reconocer el problema del ciclo en movimientos concurrentes y las salidas practicables.
- Aplicar el criterio general que predice qué otras cosas no van a encajar.
El número que quieres sumar
El fallo más común de todo el nivel cabe en una línea de código que parece intachable. Leer el valor de una hoja numérica, sumarle uno y escribir el resultado es un ciclo de lectura, modificación y escritura, y la escritura final es una asignación como cualquier otra. Si dos réplicas lo hacen a la vez partiendo del mismo valor, ambas escriben el mismo número, la regla de desempate elige una de las dos y el contador avanza una unidad en lugar de dos. No hay conflicto que señalar porque, desde el punto de vista del documento, no hubo ninguno: dos réplicas escribieron el mismo valor en la misma hoja y el sistema hizo lo único que sabía hacer.
Lo importante es que el error no está en la librería sino en la traducción. El programador tenía en la cabeza una operación —incrementar— y el documento solo entiende otra —asignar—, y en la traducción se pierde justamente la información que habría permitido fusionar bien. Un incremento es conmutativo por naturaleza y dos incrementos concurrentes se combinan sin ambigüedad; una asignación no lo es, y dos asignaciones concurrentes obligan a descartar una.
// Lo que parece un incremento y es una asignacion que pierde escrituras
doc.metricas.vistas = doc.metricas.vistas + 1;
// El contador como mapa de contribuciones: cada replica solo toca la suya
function incrementar(contador, replica, delta) {
const previo = contador[replica] ?? 0;
return { ...contador, [replica]: previo + delta };
}
function valor(contador) {
return Object.values(contador).reduce((a, b) => a + b, 0);
}
// La union es el maximo componente a componente y por eso converge
function unirContador(a, b) {
const salida = { ...a };
for (const [r, n] of Object.entries(b)) salida[r] = Math.max(salida[r] ?? 0, n);
return salida;
}
Con esa representación el contador vuelve a ser una estructura convergente de pleno derecho: cada réplica escribe únicamente en su propia entrada, no hay dos réplicas que puedan escribir la misma coordenada y la unión por máximo es conmutativa, asociativa e idempotente. Algunas librerías ofrecen un tipo contador ya construido y conviene usarlo; otras no lo incluyen y obligan a modelarlo a mano como un mapa de contribuciones, que es exactamente el fragmento anterior. Si necesitas también decrementos, la vía habitual es llevar dos mapas —uno de sumas y otro de restas— porque el máximo componente a componente deja de servir en cuanto una entrada puede bajar.
Ese contador tiene un límite que conviene conocer antes de apoyarse en él para algo importante: no sabe respetar una cota. Un inventario que no puede bajar de cero, un cupo de plazas o un saldo que no debe quedar en negativo son restricciones sobre el valor agregado, y el valor agregado no existe en ninguna réplica hasta que todas han sincronizado. Dos réplicas que decrementan a la vez desde una unidad disponible producen, cada una localmente, un resultado válido, y la suma final es negativa. La estructura convergente no está fallando: está informando de que la restricción que le pedías no es de su competencia. Las salidas conocidas son repartir el cupo por adelantado entre las réplicas, que sacrifica utilización, o sacar la decisión del documento y llevarla a un componente con autoridad, que sacrifica disponibilidad.
Un contador de contribuciones guarda una entrada por cada réplica que alguna vez lo incrementó, y esas entradas no se dan de baja por las mismas razones que impedían podar un vector causal. En un documento con muchos colaboradores y muchos dispositivos, un contador que vale doce puede pesar más que el texto que lo acompaña. Antes de sembrar el esquema de contadores, pregúntate cuáles necesitan de verdad ser convergentes y cuáles pueden ser vistas derivadas calculadas a partir del contenido, que no ocupan nada.
El diagnóstico se generaliza más allá del contador y conviene tenerlo escrito. Cualquier cálculo que lea el estado actual para producir el siguiente —sumar, restar, concatenar, promediar, mantener un porcentaje— se convierte en una asignación al escribirse y pierde escrituras concurrentes de la misma forma. Las excepciones son los agregados que ya son idempotentes y monótonos, como el máximo, el mínimo o un booleano que solo pasa de falso a verdadero: esos sí encajan en una hoja porque su fusión coincide con la del registro.
Hay una variante del mismo fallo que se disfraza lo suficiente como para pasar cualquier revisión de código: el cálculo derivado que se guarda. Un total que se recalcula y se escribe cada vez que cambia una línea, un recuento de elementos pendientes, un porcentaje de avance. Todos son lecturas del estado convertidas en escrituras, y todos pierden actualizaciones concurrentes exactamente igual que el contador. La regla que los elimina de golpe es no persistir nunca lo que se puede recalcular: si un valor es función de otros valores del documento, que sea una vista derivada y se calcule al leer. Solo merece la pena guardarlo cuando el cálculo es demasiado caro, y en ese caso hay que tratarlo como una caché con su invalidación explícita, no como un dato del documento.
Referencias: cuando el árbol quiere ser un grafo
El segundo borde aparece en cuanto el modelo deja de ser jerárquico, cosa que ocurre pronto. Una tarea asignada a una persona, un comentario que apunta a un párrafo, una etiqueta compartida por varios elementos: todos son casos en los que un nodo necesita señalar a otro que no es su descendiente. El árbol no tiene esa noción, así que la referencia se representa guardando en una hoja el identificador del nodo destino, y con eso el documento pasa a ser un grafo por encima del árbol, sin que ninguna de las garantías del árbol se extienda a esa capa nueva.
Las consecuencias son tres y ninguna es sutil. La primera es la referencia colgante: quien borra el destino no sabe quién le apunta, así que la referencia sobrevive apuntando a un nodo que ya no existe, o peor, a uno que resucitó parcialmente. La segunda es la integridad sin autoridad: comprobar que toda referencia apunta a algo vivo es una invariante global, y una invariante global no se puede mantener con una fusión local por definición, así que solo cabe verificarla después de fusionar. La tercera es la estabilidad del identificador: si el destino puede recrearse con identidad nueva —y la reasignación de claves compuestas hace exactamente eso— las referencias antiguas apuntan a la versión anterior aunque la clave legible sea la misma.
Referencia por identidad estable
Guarda el identificador interno del nodo, nunca su ruta ni su nombre, y no lo recrees jamás aunque el contenido cambie por completo.
Validación después de fusionar
La integridad referencial es una invariante global: no la mantiene el algoritmo, la comprueba una pasada posterior que decide qué hacer con lo roto.
Tolerar el destino ausente
Diseña la interfaz para que una referencia rota se muestre como tal y no como un fallo, porque va a ocurrir y no es un caso excepcional.
Índice inverso derivado
Mantén quién apunta a quién como vista derivada y recalculable, nunca como dato replicado que habría que fusionar por separado.
Hay una tentación que conviene desactivar antes de que aparezca en una revisión de código: replicar el índice inverso, es decir, guardar en el destino la lista de quienes le apuntan para poder limpiarlos al borrar. Es una idea razonable en una base de datos con transacciones y aquí es una fuente de incoherencia, porque el índice y las referencias son dos datos distintos que se fusionan por separado y nada garantiza que acaben de acuerdo. El índice inverso debe ser siempre una vista derivada del documento, recalculable en cualquier momento y nunca sincronizada como contenido propio.
Hay una variante de referencia que merece mención aparte porque es la que más problemas da y la que peor se detecta: la que apunta a una posición dentro de un texto o una lista. Un comentario anclado a un fragmento, una anotación sobre un rango, un marcador de lectura: todos guardan una localización, y una localización expresada como índice numérico deja de significar lo mismo en cuanto alguien inserta algo antes. La solución existe y las librerías serias la ofrecen —anclar a los identificadores estables de los elementos que delimitan el rango, en lugar de a números— pero hay que usarla de forma explícita, porque el índice numérico está siempre a mano y funciona perfectamente mientras solo haya un editor. Es un fallo que aparece en la primera sesión con dos personas y que obliga a migrar datos ya escritos, así que conviene decidirlo antes.
Y queda el caso del destino que resucita, que es el más desconcertante de todos. Si un nodo se elimina y una escritura concurrente lo devuelve parcialmente a la vida, las referencias que le apuntaban vuelven a estar vivas apuntando a un contenido que ya no es el que motivó la referencia. Un comentario reaparece sobre un párrafo distinto, una tarea vuelve a asignarse a un proyecto que se vació. Ninguna comprobación de integridad detecta ese caso, porque el destino existe y el identificador es correcto; lo único que ha cambiado es que el contenido perdió su sentido. Es el argumento más fuerte a favor de conservar en la referencia una copia del contexto que la justificaba, aunque sea solo para poder detectar que dejó de coincidir.
Mover un subárbol y el problema del ciclo
El tercer borde es el más elegante de los tres y el que peor se comporta. Mover un elemento de un sitio a otro no es una operación del modelo: se expresa como un borrado en el origen y un alta en el destino, y esas dos operaciones no están ligadas entre sí. Dos réplicas que muevan el mismo elemento a destinos distintos producen dos altas y dos bajas que se fusionan con las reglas ya conocidas, y el resultado más habitual es que el elemento aparezca duplicado en ambos destinos, porque cada alta sobrevivió al borrado que no la había observado.
En un árbol la situación empeora, porque los nodos que se mueven pueden ser padres unos de otros. Si una réplica mueve el nodo A dentro del nodo B mientras otra mueve B dentro de A, cada operación es válida por separado y la fusión produce una estructura en la que A es descendiente de B y B es descendiente de A. Esa configuración no es un árbol: es un ciclo que se ha desprendido de la raíz y que, desde el punto de vista de cualquier recorrido, ha dejado de existir junto con todo lo que colgaba de él.
flowchart TD I0[estado inicial con A y B colgando de la raiz] --> R1[replica uno mueve A dentro de B] I0 --> R2[replica dos mueve B dentro de A] R1 --> F[fusion de las dos operaciones] R2 --> F F --> C[A es hijo de B y B es hijo de A] C --> D[el ciclo se desprende de la raiz] D --> E[todo el contenido deja de ser alcanzable] style C fill:#f38ba8,color:#11111b style E fill:#f38ba8,color:#11111b
El movimiento concurrente en árboles replicados es un problema estudiado y con soluciones publicadas, y merece subrayarse que ninguna de ellas es gratuita: todas exigen detectar la situación y deshacer o reordenar alguna de las operaciones implicadas, lo cual saca al sistema del régimen cómodo en el que aplicar una operación es siempre un paso hacia delante. Antes de adoptar una de esas soluciones conviene comprobar si el problema se puede eliminar por modelado, que suele ser el caso.
Conviene distinguir dos casos que se discuten como si fueran uno solo, porque su dificultad no es comparable. Mover un elemento dentro de la misma colección —reordenar una lista— no plantea ningún problema estructural: el orden lo gobierna la propia secuencia y dos reordenaciones concurrentes producen un orden determinista que a lo sumo sorprende. Mover un elemento entre colecciones distintas o cambiar de padre en una jerarquía sí lo plantea, porque ahí sí hay dos operaciones que deberían ser una y no lo son. Cuando alguien afirma que las listas convergentes no saben mover, casi siempre está hablando del segundo caso.
Las dos salidas por modelado son conocidas y baratas. La primera es aplanar la jerarquía: si los elementos viven todos en una colección plana y su posición se expresa con una referencia al padre, mover deja de ser una operación estructural y pasa a ser una asignación en una hoja, con lo que dos movimientos concurrentes se resuelven con la regla de desempate del registro y a lo sumo se pierde uno. Sigue habiendo ciclos posibles, pero ahora son detectables con una pasada sobre datos locales y reparables sin tocar la historia. La segunda es prohibir el movimiento de nodos con hijos en la interfaz, restricción que en muchos productos nadie echa de menos y que elimina el caso por completo.
Sobre el duplicado conviene decir algo más, porque es el resultado más frecuente y el que peor se tolera. Cuando un elemento aparece en dos sitios tras dos movimientos concurrentes, el impulso natural es escribir una pasada que detecte los pares idénticos y elimine uno. Ese arreglo es peligroso si la pasada se ejecuta en cada réplica por separado, porque dos réplicas pueden elegir eliminar copias distintas y quedarse sin ninguna. La deduplicación, como cualquier reparación de una invariante global, tiene que ser determinista sobre la información disponible —eliminar siempre la copia cuyo identificador sea menor según un orden total, por ejemplo— o quedar reservada a un único componente con autoridad. Una reparación no determinista de un estado convergente es peor que el estado que pretendía reparar.
La jerarquía sigue existiendo tras aplanar: lo que cambia es que deja de estar codificada en la forma del árbol y pasa a estar codificada en un campo. La diferencia decisiva es que la forma del árbol es una propiedad global que ninguna fusión local puede validar, mientras que un campo con el identificador del padre es contenido corriente que se fusiona con la política que elijas y que se puede validar con una pasada sobre datos que ya tienes delante. Es el mismo movimiento que aparecerá una y otra vez en el apartado siguiente: convertir una restricción estructural en un dato comprobable.
Diseñar sabiendo dónde están los bordes
Los tres casos anteriores parecen problemas distintos y comparten una única causa, que es el criterio que permite predecir cuáles serán los siguientes. Todos son invariantes que abarcan más de un nodo, y una fusión que se define nodo a nodo no puede mantener nada que se defina sobre varios a la vez. El contador falla porque la suma es una propiedad de la secuencia de incrementos y no del último valor. La referencia falla porque su validez depende de un nodo que está en otra rama. El movimiento falla porque la acidad del árbol es una propiedad de la forma global y no de ningún nodo concreto.
Con ese criterio en la mano, el inventario de lo que tampoco va a encajar se escribe solo y conviene tenerlo delante al diseñar: la unicidad de un valor dentro de una colección, cualquier tope o cupo sobre el número de elementos, las sumas que deben cuadrar entre campos, los rangos válidos que dependen de otro campo, el orden por un criterio que se calcula del contenido, y cualquier estado que solo pueda pertenecer a un elemento a la vez. Ninguna de esas cosas la puede sostener el algoritmo de fusión, y esperar que lo haga es el error de diseño más caro de este terreno.
Lo que sí funciona son tres estrategias y conviene elegir de forma consciente cuál se aplica a cada invariante. La primera es codificarla en el tipo, sustituyendo la operación problemática por una que conmute, que es lo que hace el contador de contribuciones. La segunda es validar después de fusionar, aceptando que el documento puede pasar por estados inválidos y añadiendo una pasada que los detecta y los repara o los señala. La tercera es sacar la invariante del documento, delegándola en un componente con autoridad —un servidor, un líder elegido entre pestañas, una confirmación explícita del usuario— cuando su incumplimiento tiene consecuencias que no se pueden reparar después.
El criterio para repartir cada invariante entre las tres estrategias es el mismo que ya ordenó la elección de semántica, y por eso cierra bien el nivel: pregúntate qué ocurre si la invariante se incumple durante un rato. Si el incumplimiento es transitorio y se repara solo al fusionar, codifícala en el tipo y olvídate. Si es transitorio pero visible, valida después de fusionar y decide si reparas en silencio o se lo cuentas a alguien. Y si el incumplimiento, aunque dure un segundo, produce un efecto que ya no se puede deshacer —un cobro, un envío, un acceso concedido, un mensaje publicado— entonces esa invariante no puede vivir en el documento bajo ninguna circunstancia, por muy elegante que sea la estructura que se te ocurra para ella.
Merece la pena cerrar con una observación sobre el orden de trabajo, porque el error más común no es elegir mal sino no elegir a tiempo. Todas estas decisiones son baratas mientras el esquema esté en un editor y carísimas cuando hay documentos en dispositivos ajenos, y la diferencia no es de grado: en un sistema con autoridad central siempre existe la opción de detener el servicio y migrar, mientras que aquí no hay ningún momento en el que puedas afirmar que todas las réplicas han pasado por tu código nuevo. Una hora dedicada a clasificar invariantes antes de escribir el primer tipo vale más que cualquier refactorización posterior, porque muchas de esas refactorizaciones sencillamente no se pueden hacer.
Si tu modelo admite estados que ninguna réplica escribió pero que la fusión puede producir, hazlos representables de forma explícita en lugar de tratarlos como imposibles. Una referencia con destino ausente, un elemento duplicado tras un movimiento, una clave viva con contenido mutilado: si el tipo los contempla, la interfaz sabrá mostrarlos y el código no se romperá al encontrarlos. Un esquema que solo describe los estados deseables obliga a que cada punto de lectura descubra por su cuenta lo que ocurre cuando el mundo no coopera.
La forma más útil de guardar este nivel entero no es la lista de casos difíciles sino la razón única que los genera, porque esa razón permite reconocer el siguiente antes de tropezar con él. Un documento convergente sabe expresar con precisión la identidad de las cosas y su contenido: cada nodo tiene un nombre estable que todas las réplicas comparten, y cada hoja tiene una política de fusión que dice qué ocurre cuando dos personas escriben a la vez. Lo que el modelo no tiene es ninguna forma de expresar una relación entre dos nodos como un hecho que deba mantenerse. Puede guardar el identificador de otro nodo, pero eso es contenido y no relación: nada obliga a que el destino exista, nada impide que se borre, nada avisa a quien apunta. Puede colocar un nodo dentro de otro, pero la contención es un efecto lateral de dónde estaba el identificador cuando alguien lo escribió, no una afirmación sobre la forma del árbol que el sistema se comprometa a preservar. Y esa ausencia no es una carencia de las implementaciones actuales que una versión futura vaya a resolver: es una consecuencia directa de la propiedad que hace valioso todo el edificio, porque una relación que hay que mantener es una restricción sobre estados que están en sitios distintos, y una fusión que exigiera consultar sitios distintos para decidir dejaría de ser conmutativa, que es exactamente lo que se pagó para no tener que coordinar. Aquí está el precio real del local-first, y conviene enunciarlo sin eufemismos porque durante treinta niveles hemos ido comprando garantías a cambio de coordinación y este es el último cargo: renunciar a la autoridad central significa renunciar a que el sistema garantice cualquier propiedad que relacione dos cosas que están lejos. Lo que queda no es poco, es enorme —identidad estable, contenido que se fusiona sin perder trabajo, disponibilidad total sin red, propiedad real de los datos—, pero está delimitado, y la madurez profesional en este terreno consiste exactamente en saber dónde termina. Un ingeniero que ha entendido esto no diseña esperando que el algoritmo mantenga sus invariantes: las clasifica de antemano en las que puede codificar en un tipo, las que va a validar después de fusionar y las que va a sacar del documento porque no se pueden reparar. Esa clasificación, hecha el primer día y escrita al lado del esquema, es la diferencia entre un producto local-first que envejece bien y uno que a los dos años necesita una migración que ya no puede hacer, porque los datos están en dispositivos que no controla y que quizá no vuelvan a encenderse nunca.
- Busca en tu código todos los sitios donde se lea un valor del documento para calcular el siguiente y sustitúyelos por operaciones que conmuten o por un contador de contribuciones.
- Enumera todas las referencias entre nodos de tu modelo y comprueba que guardan identidades estables y no rutas ni nombres legibles.
- Escribe la pasada de validación que detecta referencias colgantes y decide, para cada clase de referencia, si se repara, se oculta o se muestra como rota.
- Reproduce el movimiento concurrente que produce un ciclo y comprueba si tu modelo lo permite; si lo permite, aplana la jerarquía o restringe el gesto en la interfaz.
- Haz el inventario completo de invariantes de tu dominio y clasifica cada una en codificable en el tipo, validable tras fusionar o excluible del documento.
- Añade al esquema una representación explícita de cada estado inválido alcanzable por fusión, y una prueba que confirme que la interfaz lo muestra sin romperse.