Basados en operaciones: enviar solo lo que se hizo
La familia que transmite la intención en lugar del objeto y consigue mensajes diminutos, a cambio de exigirle al canal entrega fiable, exactamente una vez y, según el diseño, orden causal.
La otra manera de construir un tipo replicado que converge invierte el reparto de responsabilidades. En lugar de mandar el objeto y confiar en un álgebra que absorbe cualquier maltrato del canal, se manda únicamente lo que se hizo —insertó esto aquí, incrementó en tres, quitó esta etiqueta— y se confía en que el canal entregue cada mensaje a todo el mundo una vez y solo una. El resultado es que el tráfico deja de depender del tamaño del documento y pasa a depender del tamaño de la edición, que es lo que la intuición esperaba desde el principio. El precio no desaparece: se muda. Todo lo que la familia anterior resolvía con tres leyes algebraicas hay que resolverlo ahora en la capa de transporte, y esa capa es un sistema entero con su propio estado, sus propios fallos y su propia complejidad.
- Separar las dos fases de una operación —preparar en el emisor y efectuar en todas las réplicas— y entender por qué la separación es obligatoria.
- Precisar qué garantías necesita el canal y cuáles son negociables según la conmutatividad de las operaciones.
- Reconocer cuándo hace falta difusión causal y cuándo basta con entrega fiable sin orden.
- Valorar el ahorro real en tráfico frente al coste de mantener el búfer de reordenación y la memoria de entrega.
Preparar y efectuar: por qué son dos funciones distintas
Una operación en esta familia nunca es lo que el usuario pulsó. Es lo que el usuario pulsó traducido a una forma que cualquier réplica pueda aplicar sin ambigüedad, y esa traducción ocurre una sola vez, en la réplica donde se originó. La fase de preparación lee el estado local, resuelve todo lo que dependa de él y produce un mensaje autocontenido. La fase de efecto aplica ese mensaje y no puede consultar nada que pueda diferir entre réplicas: ni un identificador aleatorio nuevo, ni el reloj, ni el tamaño actual de una lista.
La razón es que el efecto se ejecuta en todas partes, incluida la réplica de origen, y debe producir lo mismo en todas. Si la preparación de una inserción resuelve la posición como un índice numérico, dos réplicas que hayan aplicado operaciones distintas antes interpretarán ese índice sobre listas de longitudes distintas y divergirán. Por eso la preparación traduce la posición a algo estable: el identificador del elemento anterior, un identificador de la propia inserción, y nada más.
// Preparar mira el estado local; efectuar no puede mirar nada local
function prepararInsertar(estado, indice, texto, replica) {
const anterior = estado.orden[indice - 1]?.id ?? null; // se resuelve aqui
return {
tipo: "insertar",
id: `${replica}:${estado.reloj + 1}`, // identidad estable
anterior,
texto,
};
}
function efectuar(estado, op) {
if (estado.vistas.has(op.id)) return estado; // defensa, no garantia
const pos = op.anterior === null ? 0 : indiceDe(estado, op.anterior) + 1;
return insertarEn(estado, pos, op);
}
La prohibición de consultar el entorno durante el efecto es más amplia de lo que parece y conviene enumerarla, porque cada uno de sus incumplimientos produce una divergencia distinta y todos son fáciles de cometer sin darse cuenta. No se puede leer el reloj, porque cada réplica lo aplica en un instante distinto. No se puede generar un identificador aleatorio, porque cada réplica generaría uno diferente. No se puede consultar una configuración local, porque puede variar entre dispositivos. No se puede depender del tamaño ni del contenido actual de la estructura, porque el efecto se aplica sobre estados intermedios distintos en cada réplica. Y no se puede consultar la red, ni siquiera para validar, porque la respuesta puede diferir. Todo lo que sea variable se resuelve en la preparación y viaja dentro del mensaje.
La comprobación de la primera línea del efecto merece un comentario, porque induce a error con facilidad. Guardar los identificadores ya vistos protege frente a una entrega duplicada, y muchas implementaciones lo hacen. Pero eso no convierte a la familia en tolerante a duplicados: convierte a esa operación concreta en idempotente, y el conjunto de identificadores vistos es estado adicional que crece sin límite y que hay que podar, con el mismo problema de fondo que las lápidas. La garantía sigue estando en el canal; lo único que ha cambiado es dónde se paga.
Lo que el canal tiene que garantizar de verdad
El contrato mínimo de esta familia tiene tres cláusulas y conviene enunciarlas por separado porque no todas son igual de caras. La primera es entrega fiable: toda operación emitida acaba llegando a toda réplica, lo que exige acuses, reintentos y un registro persistente de lo que aún no se ha confirmado. La segunda es entrega exactamente una vez, que en realidad nadie implementa como tal: lo que se implementa es al menos una vez más deduplicación en el receptor, y ese segundo componente es memoria que crece. La tercera es la que separa a los diseños fáciles de los difíciles.
Entrega fiable
Toda operación llega a toda réplica. Exige acuses, reintentos y una cola durable en el emisor.
Efecto único
Aplicar dos veces no puede duplicar el efecto. Se consigue deduplicando por identificador en el receptor.
Orden causal cuando hace falta
Solo para los pares de operaciones que no conmutan. Exige búfer de retención y resumen causal en cada mensaje.
Camino de reparación
Cuando la causalidad se rompe de forma definitiva, hay que poder transferir estado completo. No es opcional.
Esa tercera cláusula es el orden, y su intensidad depende enteramente del álgebra de tus operaciones. Si todas las operaciones conmutan entre sí sin excepción, no hace falta ningún orden: pueden aplicarse en cualquiera y el resultado es el mismo. Si existen pares que no conmutan, hace falta que las réplicas los apliquen en un orden compatible, y en la práctica eso se resuelve exigiendo difusión causal fiable: una operación no se entrega hasta que se han entregado todas las que su emisor había visto al prepararla.
flowchart LR P[preparar en la replica de origen] --> E[emitir el mensaje] E --> C[canal fiable y sin duplicados] C --> B[bufer de reordenacion causal] B --> F[efectuar en cada replica] F --> G[estado convergente] style C fill:#f38ba8,color:#11111b style B fill:#f9e2af,color:#11111b style G fill:#a6e3a1,color:#11111b
Merece la pena insistir en la segunda cláusula porque se enuncia mal casi siempre. La entrega exactamente una vez no existe como propiedad de un canal, y no por falta de ingenio: un emisor que no recibe confirmación no puede distinguir entre un mensaje perdido y una confirmación perdida, de modo que solo puede elegir entre reintentar, que produce duplicados, y no reintentar, que produce pérdidas. Lo que sí existe es al menos una vez en el canal más deduplicación o idempotencia en el receptor, y esa segunda mitad es responsabilidad de tu código y no del transporte que contrataste. Todo diseño que dé por resuelta esta cláusula porque la documentación de una cola la promete está apoyándose en una promesa que no se puede cumplir.
flowchart LR A[al menos una vez en el canal] --> B[deduplicacion en el receptor] B --> C[efecto aplicado una sola vez] D[como mucho una vez en el canal] --> E[perdida silenciosa] style C fill:#a6e3a1,color:#11111b style E fill:#f38ba8,color:#11111b
La difusión causal no es magia ni una biblioteca que se importa: es un componente con estado. Cada mensaje viaja acompañado de un resumen de lo que su emisor había visto —un vector de versiones o el conjunto de dependencias directas—, y el receptor lo retiene en un búfer hasta que ese resumen queda cubierto por lo que ya aplicó. Ese búfer es memoria que puede crecer sin cota si un emisor lento deja huecos, y es una fuente inagotable de fallos difíciles: un mensaje que nunca llega bloquea indefinidamente a todos los que dependen de él, y el sistema no se cae, simplemente deja de progresar en silencio.
La patología característica de esta familia no es la corrupción sino el bloqueo silencioso. Si una operación se pierde de forma definitiva —un cliente que se reinstala perdiendo su cola de salida, una fila de mensajes purgada, un fallo de disco antes del vaciado— todas las operaciones posteriores de ese emisor quedan atrapadas en el búfer causal de los demás para siempre. Las réplicas siguen respondiendo, la interfaz sigue viva y los cambios simplemente no aparecen. Por eso todo despliegue serio de operaciones lleva un camino de recuperación que consiste, invariablemente, en transferir estado completo: cuando la causalidad se rompe, la única salida es volver por un rato a la familia de la lección anterior.
Lo que se ahorra y cómo medirlo
El beneficio es nítido y se puede cuantificar sin ambigüedad. El mensaje mide lo que mide la intención, de modo que insertar un carácter cuesta unas decenas de bytes con independencia de que el documento tenga diez kilobytes o cuarenta megabytes. El coste de aplicar es igualmente local: efectuar una inserción toca la región afectada y no recorre la estructura completa. En una sesión de edición colaborativa con varias personas escribiendo a la vez, la diferencia entre las dos familias no es de un factor pequeño, es de varios órdenes de magnitud.
El mensaje mide el cambio
El tráfico es proporcional a la actividad y no al tamaño del documento, que es la propiedad que hace viable la colaboración en tiempo real.
El efecto es local
Aplicar toca la región afectada. No hay recorrido completo de la estructura en cada sincronización.
La intención sobrevive
Un incremento sigue siendo un incremento al llegar. La información sobre qué quiso hacer el usuario no se pierde por el camino.
La historia es el registro
La secuencia de operaciones es directamente un diario auditable, reproducible y depurable desde cero.
Conviene cuantificarlo con un ejemplo concreto para tener la escala presente. Un documento de texto colaborativo de dos megabytes editado por tres personas durante una hora a un ritmo modesto de cien pulsaciones por minuto genera del orden de dieciocho mil operaciones. Bajo la familia de operaciones eso son unos cuantos cientos de kilobytes de tráfico total; bajo la familia de estado sin deltas, con una sincronización por segundo, son varios gigabytes por participante. La diferencia no admite discusión y explica por qué la edición colaborativa en tiempo real nació en el territorio de las operaciones y tardó en salir de él.
Hay un tercer beneficio menos evidente y que en varios dominios pesa más que el tráfico: la operación conserva la intención, mientras que el estado la borra. Cuando una réplica recibe un estado con un contador a siete, no sabe si alguien sumó siete o si alguien sumó diez y otro restó tres, y esa información ya no existe en ninguna parte. Cuando recibe la operación de incrementar en tres, tiene delante lo que el usuario quiso. Para auditoría, para deshacer selectivo, para explicarle a alguien por qué un valor cambió y para depurar un incidente, esa diferencia es la que decide si el problema es investigable.
El coste que no aparece en las cuentas de tráfico
El primer coste oculto es el del registro persistente. Una operación no puede considerarse emitida hasta que está a salvo en disco, porque si la aplicación se cierra entre el efecto local y el envío, la réplica de origen tendrá un estado que ninguna otra podrá alcanzar jamás. Eso significa que cada edición implica una escritura durable antes de la confirmación visual, o bien asumir explícitamente una ventana de pérdida y decir en voz alta cuánto dura. La cola de salida deja de ser un detalle del transporte y pasa a ser estado de primera clase, que hay que versionar, migrar entre versiones del esquema y compactar como cualquier otro.
El segundo es la memoria de entrega. La deduplicación por identificador solo es correcta si el identificador se recuerda al menos tanto como el canal puede retrasar un duplicado, y como ningún canal real acota ese retraso, en la práctica se recuerda todo o se acota con un corte causal: puedo olvidar los identificadores que sé que toda réplica ya aplicó. Calcular ese conjunto exige saber quién existe y hasta dónde ha llegado cada uno, es decir, reintroduce una tabla global que la familia anterior no necesitaba.
// Tres estructuras que no existian en la familia basada en estado
const colaSalida = []; // durable: operaciones emitidas sin confirmar
const yaAplicadas = new Set(); // crece con la actividad, poda con corte causal
const buferCausal = new Map(); // operaciones retenidas por dependencias ausentes
function recibir(op) {
if (yaAplicadas.has(op.id)) return;
if (!dependenciasCubiertas(op)) { buferCausal.set(op.id, op); return; }
estado = efectuar(estado, op);
yaAplicadas.add(op.id);
desbloquearDependientes(); // puede liberar en cascada
}
El tercero es el más difícil de presupuestar porque no es una estructura sino una obligación permanente: alguien tiene que vigilar el búfer causal. Un búfer que crece es la señal de que faltan operaciones, y esa señal debe llegar a alguien antes de que el usuario note que sus cambios no aparecen. Un sistema de esta familia sin métrica del tamaño del búfer y de la antigüedad de la operación retenida más vieja es un sistema que descubrirá sus incidentes por el canal de soporte.
Antes de comprometerte con difusión causal, haz el recuento. En modelos de datos típicos de aplicaciones de negocio —fichas, etiquetas, estados de flujo, contadores, adjuntos— la fracción de pares de operaciones que no conmutan suele ser pequeña, y a menudo se concentra en uno o dos campos. Si ese es tu caso, existe un diseño intermedio muy rentable: entrega fiable sin orden para la mayoría de las operaciones y un tratamiento específico para el puñado que lo necesita, en vez de imponerle a todo el sistema el contrato más caro por culpa de un campo. La difusión causal global es una decisión que se toma con demasiada facilidad porque parece la opción segura, y su coste se paga en cada mensaje durante toda la vida del producto.
La lectura superficial de este par de lecciones es que hay un compromiso entre ancho de banda y garantías de red, y que uno elige según qué le sobre. Es cierto y es insuficiente, porque deja fuera la simetría que de verdad ordena el asunto. Los dos enfoques necesitan exactamente las mismas propiedades algebraicas; lo que cambia es qué componente del sistema se encarga de proveerlas. En la familia basada en estado, la idempotencia, la conmutatividad y la asociatividad viven dentro de la función de mezcla, y por eso el canal puede ser arbitrariamente malo: cualquier maltrato que aplique —perder, duplicar, desordenar— es absorbido por una ley que ya está demostrada. En la familia basada en operaciones, esas mismas propiedades se distribuyen: la conmutatividad se le exige al diseño de las operaciones cuando es posible, y todo lo que el diseño no consiga hacer conmutar se le encarga al canal, que debe imponerlo por la fuerza mediante orden y deduplicación. Visto así, la difusión causal fiable no es un requisito de infraestructura que acompaña casualmente a la familia: es literalmente el sustituto de las leyes que la otra familia obtenía gratis, y su coste es el precio de reponerlas en otro sitio. De ahí se sigue el criterio de diseño más útil de todo el nivel, y es un criterio que se aplica operación por operación y no a la aplicación entera: cuanta más conmutatividad consigas meter dentro de tus operaciones, menos orden tendrás que pedirle a la red, y la escala va de la difusión causal completa en un extremo hasta la simple entrega fiable sin orden en el otro. Un conjunto que solo crece no necesita orden ninguno. Un contador con incrementos y decrementos tampoco. Una secuencia de texto con inserciones y borrados sí lo necesita, porque un borrado que llega antes que la inserción de lo que borra no tiene a qué aplicarse. Ese ejercicio —clasificar tus operaciones por cuánto orden exigen— determina el transporte que vas a tener que construir mucho antes de que escribas una sola línea de él, y hacerlo al revés, eligiendo primero la infraestructura, es la forma habitual de acabar pagando difusión causal para un modelo de datos que no la necesitaba.
- Escribe las operaciones de tu modelo con la separación estricta entre preparar y efectuar, y verifica que ninguna fase de efecto consulta el reloj, un aleatorio o un índice posicional.
- Construye la tabla de pares de operaciones y marca cuáles conmutan entre sí; el subconjunto que no conmuta es exactamente lo que le vas a exigir a la red.
- Implementa un canal de pruebas que pierda, duplique y reordene con probabilidades configurables, y comprueba cuál es la primera propiedad que se rompe.
- Añade deduplicación por identificador y estima cuánto crece esa memoria al mes con tu volumen real de operaciones.
- Simula la pérdida definitiva de una operación intermedia y observa cuántas operaciones posteriores quedan atrapadas en el búfer causal.
- Diseña el camino de recuperación por transferencia de estado completo y decide con qué señal se dispara.