wandres.dev
OT FRENTE A CRDT · las dos escuelas

Transformación operacional: reescribir la operación

La transformación operacional reescribe una operación para que siga teniendo sentido después de otra concurrente; nació en 1989 con el editor de grupo GROVE y se hizo célebre décadas más tarde sosteniendo la edición colaborativa en el navegador.

⏱ 17 min

El nivel anterior terminó con una tesis: cuando dos operaciones conmutan, el conflicto desaparece por construcción. La transformación operacional nace de una intuición distinta y anterior, y conviene entenderla en sus propios términos antes de juzgarla desde los CRDT. Su punto de partida es que las operaciones que la gente ejecuta de verdad —insertar aquí, borrar esto— no conmutan y no van a conmutar, porque están formuladas contra una posición en el documento y esa posición se mueve bajo los pies. La propuesta no es cambiar la operación por otra que conmute, sino conservarla tal como el usuario la generó y reescribir sus parámetros en el momento de aplicarla, de modo que exprese sobre el estado de llegada la misma intención que expresaba sobre el estado de partida. Esta lección instala esa idea con precisión, la sitúa en el artículo de 1989 que la fundó y explica por qué tardó veinte años en volverse infraestructura cotidiana.

🎯 Al terminar esta lección sabrás
  • Reconocer por qué una operación posicional caduca al viajar y por qué reenviar el documento entero no es una alternativa aceptable.
  • Formular la función de transformación y entender que su argumento oculto es el contexto, no el valor.
  • Situar el origen histórico en el sistema GROVE y en el artículo de Ellis y Gibbs de 1989.
  • Explicar qué combinación de circunstancias llevó a OT de la investigación a productos de uso masivo.

El índice que caducó mientras viajaba

Toma el documento abc replicado en dos sitios sin coordinación. La réplica A inserta X en la posición 1 y obtiene aXbc. La réplica B, sin haber visto nada, borra la posición 1 y obtiene ac. Ambas son operaciones perfectamente definidas sobre el estado que cada autor tenía delante. El problema aparece al intercambiarlas: si A aplica literalmente borrar la posición 1 sobre aXbc, elimina la X que acaba de escribir, y si B aplica literalmente insertar X en 1 sobre ac obtiene aXc. Las réplicas divergen, y no por un fallo de transporte ni por una carrera de relojes, sino porque el número 1 significaba cosas distintas en cada lado.

// La operacion viaja intacta y por eso miente al llegar
const doc = "abc";
const opA = { tipo: "insertar", pos: 1, texto: "X" };
const opB = { tipo: "borrar",   pos: 1, largo: 1 };

aplicar(aplicar(doc, opA), opB); // "abc": el borrado se come la X recien puesta
aplicar(aplicar(doc, opB), opA); // "aXc": aqui el borrado hizo lo que queria su autor

El diagnóstico exacto importa porque descarta las salidas fáciles. El índice no es un dato del documento sino una coordenada relativa a un estado, y cuando ese estado cambia la coordenada apunta a otro sitio sin avisar de nada. Bloquear el documento mientras alguien escribe devuelve la latencia al camino crítico de cada pulsación y anula el motivo de replicar. Reenviar el documento completo en cada cambio es correcto y ruinoso en ancho de banda, y además destruye la intención: el receptor recibe un resultado y ya no puede saber qué se pretendía. Y aplicar sin más una política de último escritor sobre el documento entero es la técnica que la lección tres del nivel anterior descartó con detalle.

La propuesta de la transformación operacional es la única que conserva las tres cosas que interesan a la vez —latencia local nula, tráfico proporcional al cambio e intención recuperable— y consiste en aceptar que la operación llegará descontextualizada y repararla en el destino. Si la réplica A sabe que su documento contiene una inserción de un carácter en la posición 1 que el emisor de borrar 1 no conocía, puede deducir que el carácter que aquel quería eliminar está ahora en la posición 2, y aplicar borrar 2. El resultado es aXc en ambos lados. Nada se ha bloqueado, nada se ha retransmitido entero y nadie ha perdido su edición.

flowchart TD
D0[documento abc en las dos replicas] --> A1[replica A inserta X en la posicion 1]
D0 --> B1[replica B borra la posicion 1]
A1 --> AX[estado aXbc]
B1 --> BX[estado ac]
AX --> T1[A recibe borrar 1 y lo transforma en borrar 2]
BX --> T2[B recibe insertar X en 1 y lo deja igual]
T1 --> F[aXc en las dos replicas]
T2 --> F
style D0 fill:#89b4fa,color:#11111b
style F fill:#a6e3a1,color:#11111b

La función de transformación y su argumento oculto

La pieza central es una función de dos argumentos, transformar(a, b), que recibe una operación a y otra operación b concurrente con ella y definida sobre el mismo estado, y devuelve una versión a' de la primera destinada a aplicarse después de la segunda. La condición que debe cumplir es de igualdad de estados: aplicar b y luego a' tiene que dar lo mismo que aplicar a y luego la simétrica b'. Escrita así parece una identidad algebraica menor; en realidad es la especificación completa del sistema, y el resto de la lección siguiente consiste en descubrir lo caro que sale satisfacerla.

// Insercion frente a insercion concurrente
function transformarInsertInsert(a, b) {
  if (a.pos < b.pos) return a;                                  // b queda a la derecha
  if (a.pos > b.pos) return { ...a, pos: a.pos + b.texto.length };
  // Empate exacto: hace falta un criterio total, estable y compartido
  return a.sitio < b.sitio ? a : { ...a, pos: a.pos + b.texto.length };
}

// Insercion frente a borrado concurrente
function transformarInsertDelete(a, b) {
  return a.pos > b.pos ? { ...a, pos: a.pos - b.largo } : a;
}

Ya en este fragmento minúsculo hay tres detalles que anticipan toda la dificultad del nivel. El primero es que hacen falta cuatro funciones y no una, porque cada par ordenado de tipos —inserción frente a inserción, inserción frente a borrado, borrado frente a inserción, borrado frente a borrado— tiene su propia semántica. El segundo es que la función no es simétrica: transformar a contra b y transformar b contra a son cálculos distintos que deben encajar entre sí. Y el tercero es que la transformación puede aniquilar la operación: si dos réplicas borran concurrentemente el mismo carácter, la segunda no tiene ya nada que borrar, y el sistema necesita representar explícitamente la operación vacía en lugar de tratarla como un caso de error.

// Borrado frente a borrado: los rangos pueden solaparse o desaparecer
function transformarDeleteDelete(a, b) {
  if (a.pos >= b.pos + b.largo) return { ...a, pos: a.pos - b.largo };
  if (a.pos + a.largo <= b.pos) return a;
  const solapado = Math.min(a.pos + a.largo, b.pos + b.largo) - Math.max(a.pos, b.pos);
  const restante = a.largo - solapado;
  if (restante <= 0) return { tipo: "vacia" };          // b ya lo borro todo
  return { ...a, pos: Math.min(a.pos, b.pos), largo: restante };
}

Fíjate en el desempate del caso de igualdad, porque es donde se cuela la primera decisión que la matemática no dicta. Si dos personas insertan en la misma posición, ambos órdenes producen documentos distintos y ambos son defendibles; el algoritmo elige uno mediante un identificador de sitio, y esa elección tiene que ser total, determinista e idéntica en todas las réplicas o el sistema no converge. Es exactamente la misma clase de decisión de política que el nivel anterior identificó al hablar de prioridad al alta o a la baja en un conjunto: no ha desaparecido, se ha mudado dentro de la función de transformación.

El segundo detalle es más profundo y suele explicarse mal. transformar no es una función sobre valores sino sobre contextos. Una operación no significa nada por sí sola: significa algo relativa al estado sobre el que se generó, y transformarla exige saber con precisión qué operaciones ha visto ya el destino y cuáles no. Por eso todo sistema OT arrastra, además de las funciones, una contabilidad no trivial: cada sitio guarda un registro de las operaciones ejecutadas y un vector que resume cuántas ha visto de cada réplica, y al recibir una operación remota la transforma una por una contra el sufijo exacto del registro que el emisor desconocía. La función es la parte visible; la contabilidad del contexto es la que consume el código.

💡
Si el contexto no está explícito en la operación, el sistema ya está roto

Un error recurrente en implementaciones caseras es transmitir la operación sin la marca del estado contra el que se generó, confiando en que el orden de llegada baste para reconstruirla. No basta nunca: con dos clientes puede parecer que funciona, con tres deja de hacerlo y con red intermitente falla de forma no reproducible. Cada operación debe viajar con una identificación de su contexto —un vector, un número de revisión conocido, un identificador de nodo del grafo de historia— porque el receptor necesita calcular exactamente qué sufijo de su registro debe transformar. Si en tu diseño no aparece esa marca, no tienes un sistema OT con un fallo: tienes un sistema que converge por casualidad mientras las condiciones sean benignas.

GROVE, 1989: de dónde viene la idea

La formulación original está en Concurrency Control in Groupware Systems, de C. A. Ellis y S. J. Gibbs, presentado en la conferencia SIGMOD de 1989. El contexto no era la web sino GROVE, un editor de esquemas para trabajo en grupo desarrollado en el consorcio MCC, y la pregunta que el artículo se plantea es explícitamente una pregunta de control de concurrencia: qué hacer cuando la respuesta canónica de las bases de datos —serializar y bloquear— resulta inaceptable porque hay una persona esperando delante de la pantalla y la latencia de la coordinación es visible para ella. El artículo describe un algoritmo distribuido de transformación de operaciones, conocido después como dOPT, en el que cada sitio mantiene un registro de peticiones y un vector de estado, y transforma las operaciones remotas contra las locales que el emisor no había visto.

✉️

La operación como unidad de red

Se replica lo que el usuario hizo, no el resultado. El tráfico es proporcional al cambio y la intención sigue siendo legible en el destino.

🧭

El contexto explícito

Cada operación se identifica respecto del estado sobre el que nació. Sin esa marca no se puede saber contra qué sufijo hay que transformar.

📚

El registro de historia

Cada réplica conserva las operaciones ejecutadas para poder transformar lo que llegue tarde. Es la memoria que hace posible la reparación.

🔁

La matriz de transformación

Una función por cada par ordenado de tipos de operación. Es la pieza que crece mal y a la que dedicamos entera la lección siguiente.

El aparato de contabilidad que el artículo describe merece atención porque sobrevive intacto en las implementaciones actuales. Cada sitio mantiene dos cosas además del documento: un registro con las operaciones que ha ejecutado, en el orden en que las ejecutó, y un vector de estado que cuenta cuántas operaciones ha recibido de cada participante. Cuando llega una operación remota acompañada del vector de su emisor, la comparación entre ambos vectores identifica con exactitud qué entradas del registro local eran desconocidas para quien la generó, y la operación se transforma sucesivamente contra esa sublista. Es la misma comparación de vectores que el nivel 22 construyó para detectar concurrencia, aplicada aquí no para decidir si hay conflicto sino para calcular contra qué hay que reparar.

// El sufijo del registro que el emisor no habia visto
function pendientesPara(registro, vectorEmisor) {
  return registro.filter((entrada) => entrada.contador > (vectorEmisor[entrada.sitio] ?? 0));
}

function integrar(opRemota, registro, vectorEmisor) {
  let op = opRemota;
  for (const local of pendientesPara(registro, vectorEmisor)) {
    op = transformar(op, local.operacion); // una reparacion por cada desconocida
  }
  return op;
}

Merece la pena subrayar dos rasgos del planteamiento de 1989 que explican buena parte de lo que vino después. El primero es que las operaciones consideradas son deliberadamente pobres: insertar un carácter y borrar un carácter sobre una secuencia lineal. Esa pobreza no es un descuido de un trabajo temprano, es la condición que hace manejable la matriz de transformación, y cuando los editores reales necesitaron formato, bloques anidados y movimientos de nodos, el coste creció exactamente por donde el artículo había sido prudente. El segundo es que el algoritmo está pensado para una topología entre iguales, sin servidor: cada sitio transforma contra su propio registro. Es una elección de diseño coherente con los sistemas distribuidos de la época y, como la lección tres explicará, es también la que la industria acabaría abandonando.

Lo que la hizo famosa

Entre el artículo de 1989 y la fama pública hay dos décadas y dos escalones. El primero es High-latency, low-bandwidth windowing in the Jupiter collaboration system, de Nichols, Curtis, Dixon y Lamping, publicado en UIST en 1995, que reformula el problema colocando un servidor en el centro y reduciendo la transformación general a una relación de dos partes entre cada cliente y ese servidor. Ese trabajo, y no el de 1989, es el que describe la arquitectura que casi todo OT desplegado usa hoy, y la lección siguiente a la próxima lo desarrolla en detalle porque su consecuencia para local-first es decisiva.

Entre medias hay una línea de trabajo que refinó el algoritmo entre iguales y que conviene conocer porque fija el vocabulario. El artículo de Ressel, Nitsche-Ruhland y Gunzenhäuser presentado en CSCW en 1996 introdujo adOPTed junto con un modelo geométrico del espacio de transformaciones, y el de Sun y Ellis en CSCW de 1998 propuso la familia GOT y GOTO separando dos operaciones inversas entre sí: la transformación de inclusión, que adapta una operación para que tenga en cuenta otra que sí ocurrió antes, y la de exclusión, que hace lo contrario y desmonta el efecto de una operación del contexto de otra. Esa segunda es la que permite reordenar el registro para ponerlo en la forma que la transformación necesita, y es también la que introduce buena parte de la dificultad que la lección siguiente detalla.

El segundo escalón es el navegador. Cuando la edición colaborativa se volvió una expectativa de producto en la web, OT encajaba con una precisión notable en el entorno disponible: el documento seguía siendo una cadena de texto normal, el servidor ya existía y estaba conectado por definición, los mensajes eran pequeños y el estado que había que guardar era el documento actual más un poco de historia reciente. Editores como Etherpad, el sistema Google Wave presentado en 2009 y su documento técnico sobre transformación operacional publicado en 2010, y las suites ofimáticas colaborativas que se generalizaron después, llevaron la técnica a una escala de uso que ningún trabajo de laboratorio había visto. La consecuencia cultural es que buena parte de una generación de desarrolladores conoció el problema de la edición concurrente ya envuelto en su solución OT, y esa familiaridad explica tanto la solidez de las implementaciones actuales como la resistencia inicial a mirar la alternativa.

📝
OT no es un algoritmo, es una familia con un contrato común

Hablar de el algoritmo OT induce a error, porque bajo esa etiqueta conviven propuestas con garantías bastante distintas: dOPT, adOPTed, GOT, GOTO, SOCT2 y las variantes cliente-servidor derivadas de Jupiter difieren en qué exigen del transporte, en qué propiedades demuestran y en si necesitan o no la propiedad más difícil de todas. Lo que comparten es el contrato: conservar la operación original y reparar sus parámetros contra el contexto de llegada. Cuando compares OT con otra cosa, comprueba siempre de qué variante estás hablando, porque las diferencias internas de la familia son a veces mayores que la distancia a la familia rival.

OT es la primera vez que un sistema admite que la unidad de replicación no es el estado sino la intención

Conviene detenerse en la magnitud conceptual de lo que ocurre en 1989, porque queda tapada por el aparato técnico y es lo único de esa propuesta que no ha envejecido. Hasta entonces, replicar significaba sin discusión propagar valores: un dato cambia aquí, se manda el dato nuevo allá, y toda la dificultad estaba en decidir qué valor sobrevive cuando llegan dos. Esa formulación arrastra un supuesto que nadie enunciaba porque parecía obvio, y es que el estado contiene toda la información relevante sobre lo que ha pasado. La transformación operacional es el primer sistema que dice, con una arquitectura entera detrás, que ese supuesto es falso: entre el documento ahora dice aXbc y alguien quiso meter una X justo delante de la b hay una diferencia de información que el estado no puede expresar y que resulta imprescindible para fusionar bien. El estado es una proyección con pérdida de la intención, y esa pérdida es irreversible. Fíjate en que la consecuencia va mucho más allá del texto colaborativo. Es exactamente la misma inversión que separa una tabla de saldos de un libro de asientos, un SELECT sobre la tabla actual de un registro de eventos, una copia de seguridad de un historial de versiones: en todos esos pares, el segundo miembro conserva algo que el primero ha tirado, y ese algo es lo que permite responder preguntas que el estado ya no admite —qué se pretendía, en qué orden causal, con qué información delante—. El nivel anterior llegó a esta misma frontera por otro camino, al observar que modelar la operación en vez del estado convierte conflictos en no-conflictos, y no es casualidad que ambos caminos desemboquen aquí: es el mismo hallazgo visto desde dos disciplinas, la del control de concurrencia interactivo y la del álgebra de la replicación. Lo notable es que OT y los CRDT, que el resto del nivel va a presentar como escuelas rivales, coinciden por completo en este punto y discrepan solo en el siguiente. Ambas replican operaciones y no estados; ambas aceptan que la intención es el dato primario. La bifurcación llega al preguntar qué hacer cuando una operación aterriza sobre un estado que no es el suyo. OT responde reescríbela para que encaje, y paga con una función que debe conocer todos los pares de tipos de operación y demostrar propiedades incómodas. Los CRDT responden diséñala para que encaje siempre, y pagan con identidad persistente y metadatos que sobreviven a los datos. Cuando termines el nivel y tengas que elegir, no estarás eligiendo entre replicar estado y replicar intención —esa discusión la ganó Ellis y Gibbs hace más de treinta años y la ganó para las dos escuelas a la vez—, sino entre reparar el desajuste o hacerlo imposible. Y esa segunda pregunta, a diferencia de la primera, no tiene todavía una respuesta única.

⚔️ Reconstruye la transformación mínima
  1. Implementa las cuatro funciones de transformación del par insertar y borrar sobre una cadena, incluido el desempate por identificador de sitio.
  2. Escribe un banco de pruebas que genere pares de operaciones concurrentes al azar y compruebe que los dos caminos de aplicación llegan al mismo estado.
  3. Añade un tercer sitio y observa qué hace falta en tu contabilidad para saber contra qué sufijo del registro transformar cada operación entrante.
  4. Elimina deliberadamente la marca de contexto de las operaciones y encuentra la secuencia mínima de eventos que produce divergencia.
  5. Toma un cambio real de tu aplicación y escribe qué información de intención se pierde si lo transmites como estado resultante en lugar de como operación.