wandres.dev
MIGRAR EL ESQUEMA · datos que no controlas

Migrar un CRDT es peor de lo que parece

No basta con transformar el estado actual porque el historial sigue ahí y las operaciones antiguas se siguen aplicando, y por eso a veces la única salida honesta es un documento nuevo.

⏱ 23 min

Todo lo anterior de este nivel vale para un documento tratado como un valor: una estructura que se lee, se transforma y se vuelve a escribir. Los tipos convergentes rompen esa suposición de raíz y por eso merecen una lección propia, no un apartado. En un tipo convergente el estado visible no es el dato: es una función del historial de operaciones, y ese historial permanece almacenado, se sigue replicando y se sigue aplicando. Transformar el estado materializado no toca el historial, de modo que la migración que acabas de ejecutar puede quedar deshecha en la siguiente sincronización sin que nadie haya hecho nada raro. A eso se suma una segunda complicación sin equivalente en el resto del nivel: la migración misma es una escritura, y una escritura ejecutada de forma independiente en cinco dispositivos son cinco escrituras concurrentes que la capa de fusión tratará como tales. Esta lección examina esas dos dificultades, muestra por qué las soluciones evidentes fallan, y termina explicando por qué en algunos casos la respuesta correcta es abandonar el documento y empezar otro.

🎯 Al terminar esta lección sabrás
  • Entender por qué transformar el estado materializado no constituye una migración en un tipo convergente.
  • Ver cómo una migración ejecutada localmente en cada réplica produce duplicados y conflictos artificiales.
  • Reconocer que las operaciones antiguas siguen llegando y se aplican sin consultar ningún esquema.
  • Analizar el cambio de tipo en una clave y por qué las bibliotecas reales no lo resuelven bien.
  • Valorar la sustitución por un documento nuevo con puntero, con su coste explícito para quien no actualiza.

El estado es una función del historial, y el historial se queda

Conviene fijar la distinción con precisión porque de ella se deriva todo lo demás. En un documento convencional, el fichero contiene el estado, y reescribirlo sustituye el estado anterior. En un tipo convergente lo que se persiste es el conjunto de operaciones o de deltas, y el estado que ve la aplicación es el resultado de aplicarlas todas en un orden compatible con su causalidad. Si transformas el resultado y lo guardas, no has cambiado la entrada de esa función. Has guardado una salida distinta que la próxima recomposición volverá a producir como estaba.

// Documento convencional: el estado es lo que hay
let doc = leer();
doc = migrar(doc);
escribir(doc);        // el estado anterior deja de existir

// Tipo convergente: el estado se deriva del historial
const historial = leerOperaciones();          // esto es el dato real
const estado = materializar(historial);       // esto es solo una vista
const migrado = migrar(estado);
escribir(migrado);                             // el historial sigue intacto

materializar(leerOperaciones()) === estado;    // vuelve a salir lo de antes

La consecuencia es que en un tipo convergente no existe eso de transformar el documento. Solo existe una cosa: emitir operaciones nuevas que produzcan el efecto deseado sobre el estado derivado. Una migración deja de ser una transformación y pasa a ser una escritura como cualquier otra, sujeta a las mismas reglas de concurrencia y a las mismas resoluciones de conflicto que cualquier edición humana. Ese cambio de naturaleza es el origen de todo lo demás.

⚠️
Reescribir el estado materializado produce una divergencia que reaparece días después

El síntoma tiene una firma reconocible y conviene conocerla porque el diagnóstico ingenuo apunta siempre a otro sitio. Se ejecuta la migración, se comprueba en el dispositivo que el documento ha quedado bien, se cierra el asunto. Días después, alguien informa de que el documento ha vuelto a un estado antiguo, o peor, a una mezcla incoherente en la que unos campos tienen la forma nueva y otros la vieja. Lo que ha ocurrido es que otro dispositivo, que conservaba el historial completo sin migrar, ha sincronizado, y la fusión de su historial con el estado reescrito ha producido algo que no es ninguna de las dos versiones. La reescritura del estado no es una operación reversible ni idempotente en este contexto: es una escritura arbitraria sobre una estructura cuya semántica supone que todas las escrituras están en el historial.

Ejecutarla en cada réplica multiplica sus efectos

La segunda dificultad aparece en cuanto la migración se implementa correctamente como escritura. Si el código nuevo, al abrir un documento antiguo, emite las operaciones que lo ponen al día, y ese mismo documento está abierto en cuatro dispositivos que también se actualizan, la migración se ejecuta cuatro veces de forma independiente. La capa de fusión no tiene manera de saber que las cuatro querían decir lo mismo.

// La migracion inserta un elemento por defecto en una lista
const migrar = (doc) => doc.lista.push({ nombre: "General", color: "gris" });

// Ejecutada en cuatro dispositivos sin conexion entre ellos
// resultado tras fusionar: cuatro elementos identicos y ninguno de mas
doc.lista.length; // 4

// Y si en lugar de una lista fuese un registro
const migrarCampo = (doc) => { doc.prioridad = "normal"; };
// cuatro escrituras concurrentes del mismo valor sobre la misma clave:
// con multivalor quedan cuatro versiones en conflicto sobre un campo
// que ninguna persona ha tocado jamas

La salida teórica a este problema es hacer que las cuatro ejecuciones produzcan literalmente la misma operación, con el mismo identificador, de modo que la fusión las reconozca como una sola. Es una idea correcta y es difícil de aplicar en la práctica porque choca con el modo en que las bibliotecas reales asignan identidad: la mayoría deriva el identificador de una operación de la pareja formada por el actor y un contador local, precisamente para garantizar unicidad sin coordinación. Un identificador derivado de forma determinista del documento, la versión del esquema y la ruta del campo sería exactamente lo que hace falta, y prácticamente ninguna biblioteca expone la posibilidad de construirlo.

Sin ese apoyo quedan dos salidas parciales, ambas con coste. La primera es que la migración solo la ejecute una réplica designada, lo cual reintroduce la coordinación que el modelo entero existe para evitar y falla justo en el caso interesante, que es el de un dispositivo que lleva meses sin conexión y no puede preguntar a nadie. La segunda es escribir migraciones cuyo efecto sea idempotente por naturaleza del tipo elegido, evitando listas y registros y usando estructuras donde repetir la misma afirmación no añade nada. Esa segunda salida es la única realmente disponible hoy, y condiciona el diseño mucho antes de que exista ninguna migración.

flowchart TB
D[documento en version 5] --> A[dispositivo A actualiza y migra]
D --> B[dispositivo B actualiza y migra]
D --> C[dispositivo C actualiza y migra]
A --> F[fusion]
B --> F
C --> F
F --> R[tres operaciones concurrentes con la misma intencion]
R --> X[elementos duplicados o registro en conflicto]
style R fill:#f9e2af,color:#11111b
style X fill:#f38ba8,color:#11111b

Las operaciones antiguas siguen llegando y nadie las revisa

La tercera dificultad es la que hace irreparable el problema y merece enunciarse sin rodeos: la función de fusión de un tipo convergente no consulta tu esquema. Opera sobre la estructura de datos, no sobre los tipos de tu aplicación. Cuando un dispositivo que llevaba cuatro meses apagado se conecta y envía sus operaciones, esas operaciones se aplican porque son válidas para la estructura, con total independencia de que se refieran a campos que tu migración eliminó, de que asignen a una clave un valor con el tipo antiguo o de que reinserten un elemento que la forma nueva ya no contempla.

{
  "operaciones_que_llegan_del_dispositivo_dormido": [
    { "accion": "asignar", "ruta": "duracion", "valor": 30 },
    { "accion": "asignar", "ruta": "autor", "valor": "luis" },
    { "accion": "borrar", "ruta": "etiquetas/2" }
  ],
  "lo_que_la_fusion_hace_con_ellas": "aplicarlas sin mirar ningun esquema",
  "resultado": "reaparecen campos deprecados y el documento queda a medio camino"
}

De aquí se deduce una regla de diseño que va más allá de la migración: en un sistema con tipos convergentes, el código que materializa el estado tiene que ser tolerante por construcción a encontrar cualquier forma que alguna vez fue válida. No es suficiente con que la migración sea correcta; hace falta que la lectura del estado sepa convivir indefinidamente con residuos del pasado que pueden reaparecer en cualquier momento. Esto convierte la lógica de lectura en la capa donde vive realmente la compatibilidad, y esa lógica, a diferencia de la migración, no se ejecuta una vez sino en cada recomposición del documento.

El renombrado ilustra bien lo que ocurre cuando se ignora esto. Renombrar una clave en un mapa convergente es borrar una y crear otra, y ambas operaciones quedan sujetas a la política de conflicto del tipo. Con política de que gana la adición, una escritura antigua sobre la clave vieja la resucita después de haberla borrado. Con política de que gana el borrado, una escritura nueva concurrente con el borrado desaparece. Ninguna de las dos políticas es incorrecta y ninguna de las dos hace lo que quería la migración, porque la migración quería expresar una equivalencia entre dos claves y el tipo no dispone de ningún concepto para eso.

ℹ️
El cambio de tipo en una clave es el caso que las bibliotecas resuelven peor

Merece un párrafo aparte porque es donde el comportamiento real se aleja más de lo esperado. Si una clave contenía un tipo de texto colaborativo y la versión nueva quiere que contenga un contador, dos réplicas pueden sostener a la vez tipos distintos bajo la misma clave, y ahí las bibliotecas divergen en su respuesta. Algunas conservan ambos valores como un conflicto que la aplicación debe resolver leyendo explícitamente las alternativas, lo cual es honesto pero obliga a escribir esa lógica. Otras fijan el tipo en el momento de crear el objeto, de modo que volver a crearlo produce una entidad distinta y las ediciones hechas sobre la primera quedan huérfanas sin aviso. En ambos casos el resultado es que un cambio de tipo dentro de un documento convergente no es una operación soportada, sino un comportamiento indefinido con distintas formas de manifestarse. La conclusión práctica es incómoda y conviene aceptarla pronto: en este modelo, el tipo de una clave es parte de su identidad y cambiarlo equivale a crear otra clave.

Cuando la única salida honesta es un documento nuevo

Reunidas las tres dificultades, hay una clase de cambios para la que no existe migración correcta dentro del documento: los que alteran la identidad o el tipo de estructuras existentes, o los que reorganizan el árbol de manera que las operaciones antiguas dejan de tener un destino con sentido. Para esa clase, la única salida sin ambigüedades es dejar de intentar transformar el documento y construir otro.

El procedimiento tiene forma conocida porque es el mismo patrón de capa inmutable y puntero mutable que apareció al estudiar el almacén direccionado por contenido. Se crea un documento nuevo con identidad propia, se genera su contenido inicial mediante una traducción determinista del estado del antiguo, se marca el antiguo como cerrado y se deja un puntero que dice cuál es el vigente. La coordinación deja de ser sobre el contenido, que es grande y concurrente, y pasa a ser sobre un puntero, que es diminuto y admite una regla de desempate sencilla.

{
  "documento_antiguo": {
    "id": "doc-91",
    "esquemaV": 5,
    "estado": "cerrado",
    "sucesor": "doc-91-v2"
  },
  "documento_nuevo": {
    "id": "doc-91-v2",
    "esquemaV": 6,
    "origen": "doc-91",
    "traducido_en": "una unica replica designada, de forma determinista"
  }
}

El coste hay que decirlo entero y no esconderlo detrás del elegante cambio de nivel. El historial se corta: el documento nuevo no comparte identidad de operaciones con el antiguo, así que las ediciones anteriores dejan de ser navegables desde él y la atribución de autoría por cambio se pierde salvo que se reconstruya aparte. Y hay algo peor: un dispositivo que nunca se actualice seguirá editando el documento antiguo indefinidamente, sin ninguna señal de que su trabajo ya no llega a nadie. Ese escenario no se puede evitar por completo, solo mitigar, y la mitigación consiste en que la versión antigua, si se diseñó con visión, sepa reconocer el campo que la declara cerrada y avisar a la persona en lugar de dejarla trabajar en el vacío. Como ese campo tiene que existir en la versión antigua, hay que haberlo publicado antes de necesitarlo, que es la moraleja recurrente de todo este nivel.

En un tipo convergente no migras el estado: negocias con un pasado que nunca termina de llegar

Conviene entender por qué esta lección es la más difícil del nivel, porque el motivo no es la complejidad de las bibliotecas sino algo estructural que ya estaba decidido mucho antes. Un tipo convergente logra converger sin árbitro porque sustituye la autoridad por la evidencia: conserva el historial precisamente para que ninguna réplica necesite permiso de nadie para afirmar lo que sabe. Esa conservación es lo que permite que un dispositivo apagado durante medio año se reincorpore y no pierda nada. Y es exactamente la misma propiedad que hace imposible la migración en el sentido habitual del término, porque migrar significa declarar que cierta forma del pasado ya no es válida, y eso es una afirmación de autoridad sobre un historial que se diseñó para no admitir ninguna. La migración y la convergencia sin árbitro son la misma tensión mirada desde dos lados, y por eso ninguna biblioteca la ha resuelto: no es un hueco en la implementación, es una consecuencia del contrato. De ahí salen las tres reformulaciones que hay que llevarse. La primera es que una migración aquí es una escritura, no una transformación, y por tanto obedece a las reglas de concurrencia de cualquier otra escritura, incluida la de duplicarse tantas veces como réplicas la ejecuten. La segunda es que la compatibilidad no vive en la migración sino en el materializador, porque la migración se ejecuta una vez y el materializador se ejecuta siempre, y solo él está presente el día que llega una operación de hace tres años. La tercera, y la que más cuesta aceptar, es que hay cambios que no tienen migración correcta, y frente a ellos la respuesta profesional no es forzar una que casi funcione, sino cortar limpiamente: documento nuevo, puntero al sucesor, antiguo cerrado y coste asumido por escrito. Un equipo que interioriza esto deja de preguntarse cómo migrar un tipo convergente y empieza a hacerse la pregunta útil, que es qué parte de mi modelo puedo permitirme que sea convergente, sabiendo que todo lo que ponga ahí queda comprometido en su forma casi para siempre. Los diseños que envejecen bien no son los que dominan las migraciones convergentes, sino los que colocaron dentro de la estructura convergente únicamente aquello cuya forma estaban dispuestos a defender durante toda la vida del producto, y dejaron fuera todo lo demás.

⚔️ Comprueba qué le pasa de verdad a tu documento convergente
  1. Ejecuta tu migración sobre el estado materializado, vuelve a componer desde el historial y comprueba si el resultado sobrevive.
  2. Simula la migración en tres réplicas aisladas, fusiónalas y cuenta los duplicados o los conflictos que aparecen sobre campos que nadie tocó.
  3. Guarda una copia del historial antes de migrar, sincronízala cuatro meses después y anota en qué estado queda el documento.
  4. Comprueba qué hace tu biblioteca cuando dos réplicas asignan tipos distintos a la misma clave y documenta el comportamiento observado.
  5. Audita tu materializador y añade una prueba que le entregue una operación con la forma de dos versiones atrás.
  6. Escribe el procedimiento completo de sustitución por documento nuevo, incluido qué ve una persona cuyo dispositivo no se actualizará nunca.