wandres.dev
SECUENCIAS I · el problema del orden

Por qué los índices no valen: tu tres ya no es mi tres

Un índice nombra una posición relativa a una historia, así que si borro la uno mientras insertas en la tres tu número deja de significar lo mismo: el problema que la transformación operacional resuelve transformando y el CRDT evitando.

⏱ 18 min

La lección anterior dejó establecido que la posición es un dato que hay que representar. Falta explicar por qué el candidato obvio —el índice, ese número que todos los lenguajes usan para hablar de listas— es justamente el candidato imposible. La respuesta cabe en una frase y conviene leerla despacio: un índice no nombra un elemento, nombra una cuenta de elementos anteriores, y esa cuenta cambia cada vez que alguien inserta o borra por delante. Cuando tú y yo trabajamos sobre copias distintas, mi historia y tu historia divergen, y tu número tres está expresado en unas coordenadas que en mi copia ya no existen. Esta lección diseca esa avería y presenta las dos escuelas que nacieron de ella: una que traduce los números de un sistema de coordenadas al otro, y otra que decide no tener números que traducir.

🎯 Al terminar esta lección sabrás
  • Ver el índice como una coordenada relativa a una historia y no como el nombre de un elemento.
  • Reproducir el desfase clásico y saber en qué condiciones exactas aparece y en cuáles no.
  • Entender qué hace una función de transformación y por qué su corrección es tan difícil de demostrar.
  • Formular la alternativa del CRDT como eliminación del problema en lugar de solución del problema.

Un índice nombra una cuenta, no un elemento

Cuando escribes lista[3] no estás señalando a un objeto: estás pidiendo el objeto que quede después de saltar tres. El índice es una descripción por rodeo, y su referente depende por completo del contenido de lo que hay delante. En una estructura de datos local esa dependencia es inofensiva, porque solo hay una copia y solo hay una historia. En cuanto existen dos copias, la misma expresión pasa a designar objetos distintos en cada una, y ninguna de las dos está equivocada: ambas evalúan correctamente una descripción que dejó de ser la misma descripción.

Hay una comprobación mental que delata al culpable en cualquier sistema: pregúntate si el valor que estás enviando seguiría significando lo mismo dentro de un mes, en otra máquina, sobre un estado que ha cambiado. Un identificador pasa esa prueba; un índice no la pasa ni un segundo después de haberlo calculado.

El caso mínimo es de manual y conviene tenerlo memorizado, porque es la forma que adoptan casi todos los fallos reales. Partimos de la lista [a, b, c, d]. Tú insertas X en la posición tres, con la intención evidente de dejarlo entre c y d. Yo, sin verte, borro la posición uno, que es b. Cuando mi borrado llega a tu copia y tu inserción llega a la mía, si ambos aplicamos los números tal cual llegaron, tu X aterriza en un sitio que nunca quisiste.

flowchart TD
I[estado inicial a b c d] --> T[tu insertas X en el indice 3]
I --> Y[yo borro el indice 1 que es la b]
T --> TA[tu copia a b c X d]
Y --> YA[mi copia a c d]
TA --> F[aplico tu insercion tal cual en el indice 3]
YA --> F
F --> R[resultado a c d X en vez de a c X d]
style R fill:#f38ba8,color:#11111b

Lo que ha pasado tiene un nombre preciso: tu operación venía expresada en el sistema de coordenadas del estado [a, b, c, d], y yo la he aplicado sobre el estado [a, c, d], que es un sistema de coordenadas distinto. Nadie ha programado mal nada. El error está en haber transportado un número entre dos marcos de referencia sin convertirlo, y es exactamente el mismo tipo de error que sumar millas a kilómetros.

Vale la pena aislar la condición exacta bajo la que aparece el desfase, porque delimita cuándo puedes ignorarlo y cuándo no. Una operación remota con índice i es segura únicamente si todas las operaciones locales que ella no vio actuaron en posiciones mayores o iguales que i. Basta con que una sola de ellas haya tocado el prefijo para que el número llegue corrido, y el corrimiento se acumula: tres borrados por delante desplazan tres posiciones. Como no hay forma de saberlo mirando la operación, el receptor necesita conocer la historia completa que el emisor no vio, y ese requisito es el que arrastra toda la complejidad posterior.

// Aplicar tal cual: correcto solo si nada concurrente toco el prefijo
function esSeguraSinTransformar(remota, localesNoVistas) {
  return localesNoVistas.every((op) => op.indice >= remota.indice);
}

const remota = { tipo: "ins", indice: 3, valor: "X" };
esSeguraSinTransformar(remota, [{ tipo: "del", indice: 5 }]); // true
esSeguraSinTransformar(remota, [{ tipo: "del", indice: 1 }]); // false: corrimiento
📝
El desfase no necesita mala suerte ni carreras de milisegundos

Conviene desmontar la intuición de que esto es un caso raro que solo aparece bajo mucha concurrencia. El desfase se produce siempre que dos operaciones concurrentes toquen el mismo prefijo, y el prefijo de cualquier posición incluye todo lo que hay antes: en un documento de texto, cualquier edición cerca del principio desfasa a todas las que vengan después de ella. La única razón de que las aplicaciones ingenuas parezcan funcionar es que en la mayoría de sesiones solo hay una persona escribiendo, y con un único autor no existen coordenadas rivales. En cuanto entra la segunda persona, la probabilidad de desfase no es baja: es prácticamente uno en cualquier sesión de trabajo de duración normal.

La escuela que traduce: transformación operacional

La primera respuesta histórica al problema es la más directa: si el número está expresado en unas coordenadas equivocadas, conviértelo. Esa es la idea entera de la transformación operacional, publicada en 1989 con el sistema GROVE y llevada al gran público veinte años después por el editor colaborativo de Google. Antes de aplicar una operación remota, se la transforma contra cada operación local que la remota no vio, ajustando su índice para compensar lo que esas operaciones movieron.

// Transformar una insercion contra un borrado concurrente que la precede
function transformarInsercionContraBorrado(ins, del) {
  if (del.indice < ins.indice) return { ...ins, indice: ins.indice - 1 };
  return ins; // el borrado ocurria detras y no desplaza nada
}

const tuya = { tipo: "ins", indice: 3, valor: "X" };
const mia  = { tipo: "del", indice: 1 };

transformarInsercionContraBorrado(tuya, mia); // indice 2, que es lo que querias

La función parece sencilla y en este caso lo es. La dificultad aparece cuando se enumeran todos los pares posibles de tipos de operación, todas las posiciones relativas dentro de cada par y todas las combinaciones de tres o más operaciones concurrentes. Un sistema de transformación completo debe satisfacer dos propiedades de consistencia con nombre propio, y la segunda de ellas —la que exige que transformar en distinto orden dé el mismo resultado— resultó estar incumplida durante años en varios algoritmos publicados y revisados por pares. La lista de artículos que corrigen artículos anteriores es uno de los folclores más citados del campo.

Conviene entender de dónde sale esa dificultad, porque no es descuido de nadie. El número de casos que hay que cubrir crece con el cuadrado del número de tipos de operación, y cada caso exige decidir el comportamiento cuando las posiciones coinciden exactamente, que es donde se esconden casi todos los fallos. Peor aún, la segunda propiedad de consistencia relaciona tríos de operaciones concurrentes, de modo que ningún par de funciones puede verificarse por separado: la corrección es una propiedad del conjunto entero de transformaciones, y comprobarla a mano sobre una tabla de dos dígitos de entradas es exactamente el tipo de tarea en la que la revisión humana falla.

💡
La transformación operacional no está muerta ni es un error histórico

Sería injusto quedarse con la caricatura de que la transformación operacional fracasó. Funciona, está en producción bajo millones de sesiones diarias y tiene una ventaja real que los CRDT tardaron veinte años en igualar: las operaciones son diminutas, apenas un tipo, un índice y un carácter, sin metadatos que arrastrar ni identificadores que almacenar. Su precio no es la corrección sino la topología, porque el conjunto de transformaciones se simplifica enormemente si existe un servidor que serializa todas las operaciones en un orden común. Ese servidor es justo lo que una arquitectura local-first no quiere presuponer, y esa es la razón concreta de que el resto del track vaya por el otro camino, no una supuesta superioridad general del enfoque.

La escuela que evita: no tener números que traducir

La segunda respuesta llega en 2006 con WOOT y da un rodeo que en su momento pareció extravagante. Si el problema nace de que el índice es relativo a una historia, deja de usar índices. Que cada elemento reciba en el momento de insertarse un identificador propio, inmutable y comparable, y que la operación de inserción no diga en la posición tres sino con este identificador, que significa lo mismo en todas las réplicas y en todos los momentos porque no depende de qué haya delante.

El rodeo pareció extravagante por una razón concreta que hoy se olvida: en 2006 la memoria y el ancho de banda eran mucho más caros, y proponer que cada carácter de un documento cargara con varios bytes de metadatos permanentes sonaba a despilfarro frente a un método ya probado que enviaba tres campos por operación. Lo que cambió el veredicto no fue una mejora del algoritmo sino un cambio en el precio relativo de los recursos, más la aparición de un requisito nuevo —trabajar sin ningún servidor que serialice— que la transformación operacional cumple mucho peor. Es un buen recordatorio de que las decisiones de arquitectura envejecen cuando cambian los costes, no solo cuando aparecen ideas mejores.

🧭

Coordenada relativa

El índice se interpreta contra un estado. Cambia el estado, cambia el referente, y hay que convertir antes de aplicar.

📌

Coordenada absoluta

El identificador de posición se interpreta contra nada. Significa lo mismo en cualquier réplica y en cualquier instante.

🔁

Transformar

Se conserva la operación original y se ajusta al llegar. Poco espacio por operación, mucha lógica y demostraciones difíciles.

🚫

Evitar

Se paga espacio en el identificador y a cambio la fusión se vuelve una unión sin lógica de ajuste ni casos cruzados.

El cambio de coste que introduce ese rodeo es el eje que gobierna todo lo que queda del nivel. Transformar es barato en espacio y caro en lógica: la operación pesa unos pocos bytes y la corrección exige un enrejado de casos que hay que demostrar. Evitar es lo contrario: la lógica de fusión se reduce a unir conjuntos y ordenar, algo que se demuestra en un párrafo, pero cada elemento arrastra para siempre un identificador que hay que guardar, transmitir y comparar.

// La misma insercion expresada de las dos maneras
const porIndice = { tipo: "ins", indice: 3, valor: "X" };
// Solo es interpretable sabiendo exactamente que habia delante

const porIdentificador = { id: "0.625", valor: "X" };
// Interpretable siempre: va entre todo lo menor y todo lo mayor que 0.625

Repara en que el segundo objeto no menciona ningún estado, ninguna historia y ninguna réplica. Es una afirmación autocontenida sobre dónde vive ese carácter, y por eso puede llegar tarde, repetido o en cualquier orden sin que su significado se altere. Esa autonomía es lo que permite que la fusión sea idempotente y conmutativa sin esfuerzo, y es también lo que obliga a pagar el espacio del identificador: la información que la transformación reconstruye en el momento de aplicar, el identificador la lleva escrita encima desde que nace.

La idempotencia merece un comentario aparte porque es una ventaja operativa que se subestima. Con transformación, entregar dos veces la misma operación corrompe el documento, así que el transporte debe garantizar entrega exactamente una vez y en el orden causal correcto, lo cual obliga a numerar, acusar recibo y recuperar huecos. Con identificadores, aplicar dos veces la misma inserción no hace nada porque insertar en un conjunto lo que ya está es una operación neutra, y eso permite transportes mucho más tontos: reenviar sin miedo, sincronizar por diferencia de conjuntos, aceptar mensajes duplicados de dos rutas distintas. Buena parte de la simplicidad operativa de los sistemas local-first modernos viene de aquí y no del algoritmo en sí.

Queda una objeción que conviene atender antes de cerrar la sección, porque se plantea siempre. Si el identificador es autocontenido, ¿no bastaría con dar a cada elemento un identificador aleatorio y ordenarlos por él? No, y el motivo es exactamente el que ocupa la lección siguiente: un identificador aleatorio ordena, pero no permite pedir quiero estar entre estos dos, que es lo único que hace falta para insertar en medio. Ordenar y poder insertar en cualquier hueco son requisitos distintos, y el segundo es el difícil.

Lo que las dos escuelas comparten

Vale la pena cerrar sin maniqueísmo, porque las dos familias resuelven el mismo problema y no son tan ajenas como sugiere su literatura. Ambas parten de aceptar que las réplicas divergen y que ninguna tiene autoridad; ambas exigen que el resultado sea independiente del orden de llegada; y ambas acaban necesitando algún tipo de contabilidad causal para saber qué operaciones se han visto ya. La diferencia está en dónde colocan la información que resuelve el desfase, y esa colocación es una decisión de ingeniería, no una cuestión de bandos.

De hecho, la frontera entre ambas es más porosa de lo que sugieren los nombres. Se ha demostrado que ciertos algoritmos de una familia se pueden reformular como algoritmos de la otra sin cambiar su comportamiento observable, lo cual indica que la distinción es de presentación tanto como de fondo. Lo que sí es una diferencia real y no reducible es el requisito topológico: si tu arquitectura garantiza un punto de serialización, tienes disponibles las dos familias y la transformación suele salir más barata; si no lo garantiza, y local-first consiste precisamente en no garantizarlo, la familia de los identificadores es la que queda en pie.

La transformación la coloca en el receptor, que reconstruye el ajuste a partir de lo que sabe. El CRDT la coloca en el dato, que nace con su posición dentro. Poner información en el receptor ahorra espacio y multiplica los casos; ponerla en el dato multiplica el espacio y colapsa los casos a uno. Los algoritmos modernos han empezado a mezclar ambas ideas —guardar operaciones al estilo de la transformación y reconstruir los identificadores solo cuando hace falta— y esa mezcla es una de las líneas más activas del campo hoy.

Hay una consecuencia práctica que conviene extraer antes de cerrar, porque afecta a decisiones que probablemente tengas tomadas ya. Aunque elijas el camino del identificador, tu interfaz sigue hablando en índices: el cursor está en la posición cuatrocientos doce, la selección va del mil al mil cincuenta, el elemento arrastrado venía del tercer puesto. Esa frontera entre la vista, que piensa en índices, y el modelo, que piensa en identificadores, es un sitio donde se acumulan errores reales, y la regla que los evita es simple de enunciar: traduce del índice al identificador en el instante en que el usuario actúa, nunca después. Guardar un índice para usarlo cuando llegue la respuesta del servidor es reintroducir el problema entero en la capa de presentación.

ℹ️
El cursor ajeno es el mismo problema y suele olvidarse

La posición del cursor de otra persona, esa que tu interfaz dibuja como una rayita de color con su nombre, es una coordenada exactamente igual de frágil que cualquier otra. Si la transmites como número y quien la recibe ha editado por delante, la rayita aparece a varias palabras de donde está esa persona en realidad, y el efecto es especialmente confuso porque el usuario ve moverse un cursor ajeno sin motivo. La solución es la misma que para todo lo demás y conviene aplicarla desde el principio: transmitir la presencia como identificador de posición y traducirla a índice solo en el momento de pintar. Las librerías maduras lo hacen así, y es una de las razones por las que su capa de presencia parece más complicada de lo que uno esperaría.

La lección general: nombrar por descripción falla cuando la descripción se evalúa en otro sitio

Lo que acabamos de ver excede con mucho el asunto de las listas, y quien lo reconozca en esta forma abstracta lo encontrará después en media docena de sitios que parecían no tener relación. Hay dos maneras de referirse a una cosa, y toda la ingeniería de sistemas distribuidos vive de saber cuál está usando. Puedes nombrar por descripción, diciendo qué propiedades cumple lo que buscas: el cuarto elemento, la fila que va después de aquella, el archivo más reciente de esta carpeta. O puedes nombrar por designación rígida, dando un nombre que se pegó al objeto en su nacimiento y que no se reevalúa jamás: un identificador aleatorio, un hash de contenido, una clave primaria inmutable. La descripción es cómoda, corta y legible, y funciona sin problemas mientras quien la escribe y quien la evalúa comparten el mundo en que se evalúa. Se rompe, siempre y sin excepciones, cuando el mundo del emisor y el del receptor han divergido, porque entonces la misma frase selecciona objetos distintos sin dar ninguna señal de que algo va mal. Ese es literalmente el fallo del índice tres, y es también el fallo de la ruta de un archivo que alguien movió, el del número de línea de un parche contextual cuando el fichero cambió por arriba, el del desplazamiento de bytes en un formato que se editó en otra máquina, y el del puntero a memoria después de que el asignador compactara el montón. Todos son la misma avería con distinto disfraz, y todos se curan con el mismo movimiento: sustituir la descripción por un nombre que no se reevalúe. Fíjate además en que el precio es siempre el mismo y siempre se paga en la misma moneda. La designación rígida obliga a inventar nombres y a guardarlos, de modo que engorda el dato para siempre a cambio de que su significado no dependa de nada externo. Git tomó esa decisión con los hashes de contenido y por eso puede fusionar historias que nunca se vieron; los sistemas de archivos la toman con los inodos y por eso mover un fichero no rompe a quien lo tenía abierto; y los CRDT de secuencia la toman con los identificadores de posición y por eso pueden fusionar sin coordinar. La pregunta que conviene llevarse de esta lección no es cuál de las dos escuelas de edición colaborativa gana, sino esta otra, mucho más portátil: en mi sistema, qué cosas estoy nombrando por descripción, y qué pasa el día en que emisor y receptor dejen de compartir el mundo donde esa descripción se evalúa.

⚔️ Encuentra las coordenadas relativas de tu sistema
  1. Reproduce el desfase de la primera sección con dos copias de un array en memoria y comprueba que aplicar los índices tal cual produce el resultado equivocado.
  2. Escribe las cuatro funciones de transformación del par inserción y borrado, cubriendo las dos posiciones relativas de cada combinación.
  3. Construye un caso con tres operaciones concurrentes en el que dos órdenes distintos de transformación den resultados distintos, y explica cuál de las dos propiedades de consistencia se está incumpliendo.
  4. Reescribe una de tus operaciones de lista para que viaje con un identificador en lugar de con un índice y mide cuántos bytes cuesta el cambio por elemento.
  5. Comprueba qué hace tu sistema si una misma operación llega dos veces, y anota si esa robustez la aporta el algoritmo o el transporte.
  6. Recorre tu sistema entero buscando referencias por descripción: rutas de archivo, números de línea, desplazamientos, posiciones de array, y marca las que crucen una frontera de red.
  7. Revisa cómo transmites la posición del cursor ajeno y comprueba qué dibuja tu interfaz cuando la otra persona edita cien caracteres por delante de tu selección.