wandres.dev
CRDT II · estado, operaciones y deltas

CRDT delta: enviar el trozo que cambió sin perder la mezcla

El delta-mutador produce un fragmento que sigue siendo un elemento del retículo, de modo que el mensaje mide lo que mide la edición y las tres leyes de la mezcla se conservan intactas.

⏱ 19 min

La familia basada en estado tenía un contrato de red imbatible y una factura de ancho de banda que la hacía inviable para cualquier documento que no cupiese en un mensaje. Durante años esa tensión se dio por irreductible y se tomó como el argumento decisivo a favor de las operaciones. La salida llegó al reformular la actualización local: en lugar de una función que lleva un estado a otro estado, se define una función que produce el fragmento de estado que la actualización aporta, con la condición de que ese fragmento sea, en sí mismo, un elemento legítimo del retículo. Al serlo, se puede enviar en lugar del objeto entero y el receptor lo trata con la misma mezcla de siempre, sin distinguirlo de un estado completo. Es una idea pequeña con consecuencias desproporcionadas, y es la que hizo prácticos a los tipos basados en estado.

🎯 Al terminar esta lección sabrás
  • Definir el delta-mutador y comprobar la condición que lo hace correcto respecto de la actualización que sustituye.
  • Entender el delta-grupo como acumulación local y por qué la asociatividad lo autoriza sin demostrar nada nuevo.
  • Reconocer el papel del intervalo causalmente consistente y qué se rompe cuando se envían deltas sueltos.
  • Saber cuándo un sistema delta debe volver a enviar el estado completo y cómo se decide esa vuelta.

El delta-mutador y la condición que lo define

Un delta-mutador asociado a una actualización es una función que, dado el estado actual, devuelve un fragmento tal que mezclarlo con ese estado produce exactamente lo mismo que habría producido la actualización original. Escrito como identidad: mezclar(estado, delta(estado)) debe ser igual a actualizar(estado). Nada más se exige, y de esa única condición se sigue todo el aparato, porque el fragmento devuelto pertenece al mismo conjunto de estados y por tanto es mezclable con cualquier otro elemento, en cualquier orden y cuantas veces haga falta.

// El delta no es un parche: es un estado minusculo del mismo tipo
function deltaIncrementar(estado, replica, n = 1) {
  return { [replica]: (estado[replica] ?? 0) + n }; // un solo componente
}

const local  = mezclar(estadoActual, deltaIncrementar(estadoActual, "A"));
const remoto = mezclar(estadoDelPar, deltaIncrementar(estadoActual, "A"));
// La misma funcion de mezcla sirve para el fragmento y para el estado entero

Repara en el detalle que hace que esto funcione y que distingue un delta de un parche convencional. Un parche describe una transformación y depende del contexto sobre el que se aplica: aplicarlo dos veces daña, aplicarlo fuera de orden daña, aplicarlo sobre una base distinta falla. Un delta no describe una transformación, describe un estado, y por eso hereda las tres leyes sin discusión. Mezclarlo dos veces es inofensivo por idempotencia. Mezclarlo con deltas de otras réplicas en cualquier orden da lo mismo por conmutatividad y asociatividad. Y perderlo solo retrasa la convergencia, porque el estado del emisor sigue conteniéndolo.

Conviene también notar el valor concreto que devuelve el ejemplo: es el contador acumulado de la réplica, no el incremento de uno. Esa elección no es cosmética. Si el delta contuviese el incremento aislado, la mezcla por máximo lo interpretaría mal y la duplicación dejaría de ser inocua. El delta tiene que estar expresado en el mismo lenguaje que el estado, y ese requisito es lo que a veces obliga a rediseñar la representación antes de poder deltificar un tipo.

El ejercicio se repite con cada estructura y en la mayoría sale sin esfuerzo. En un conjunto que solo crece, el delta de un alta es el conjunto de un elemento. En un mapa de registros con marca de versión, el delta de una escritura es el mapa con esa única clave. En un máximo, el delta es el propio valor. La regla general que emerge es que el delta de una actualización es la proyección del estado resultante sobre la parte que la actualización tocó, y por eso siempre es un elemento legítimo: es un trozo del retículo, no una descripción de cómo llegar hasta él.

// Un conjunto que solo crece: el delta de un alta es un conjunto de un elemento
const mezclarConjunto = (a, b) => new Set([...a, ...b]);
const deltaAnadir = (_estado, elemento) => new Set([elemento]);

// Un registro por clave: el delta de una escritura toca una sola clave
const deltaEscribir = (estado, clave, valor, replica) => ({
  [clave]: { valor, version: (estado[clave]?.version ?? 0) + 1, replica },
});

Delta-grupos: acumular localmente y enviar una vez

Enviar un mensaje por cada pulsación de tecla es correcto y es un desastre operativo. La solución no requiere teoría nueva: como la mezcla es asociativa, los deltas producidos entre dos envíos pueden mezclarse entre sí en local para formar un único fragmento acumulado —un delta-grupo— cuyo efecto es idéntico al de haberlos enviado uno a uno. La emisión pasa a ser periódica, el número de mensajes deja de depender del ritmo de tecleo y el tamaño del grupo queda acotado por lo que se haya tocado en el intervalo, no por el documento entero.

// Acumular deltas en local y vaciar por tiempo o por tamano
let pendiente = {};

function aplicarLocal(d) {
  estado    = mezclar(estado, d);
  pendiente = mezclar(pendiente, d); // la asociatividad lo autoriza
}

function vaciar(enviar) {
  if (Object.keys(pendiente).length === 0) return;
  enviar(pendiente);
  pendiente = {}; // solo tras confirmar; si no, se reintenta sin dano
}
flowchart LR
U1[edicion] --> D1[delta 1]
U2[edicion] --> D2[delta 2]
U3[edicion] --> D3[delta 3]
D1 --> G[delta grupo acumulado]
D2 --> G
D3 --> G
G --> R[mezcla en la replica remota]
R --> S[mismo resultado que enviarlos sueltos]
style G fill:#a6e3a1,color:#11111b
style S fill:#89b4fa,color:#11111b

La línea del vaciado que devuelve el pendiente a vacío solo tras confirmar el envío merece atención porque es donde vive la tolerancia a fallos de todo el esquema. Si el envío falla, el grupo sigue ahí y se reintenta; si el envío llegó pero la confirmación se perdió, el reintento entrega un duplicado que la idempotencia absorbe sin consecuencias. Es el mismo comportamiento que ofrecería una cola con reintentos, obtenido sin cola, sin números de secuencia y sin protocolo, únicamente porque el objeto que viaja es un elemento del retículo.

El intervalo causal: la condición que no se puede saltar

Hasta aquí la técnica parece gratuita, y hay exactamente un lugar donde no lo es. Para tipos cuyo delta es independiente del contexto —contadores, conjuntos que solo crecen, máximos, registros de última escritura— basta con mezclar lo que llegue. Pero para tipos donde una operación depende causalmente de otra, mezclar un delta sin haber mezclado antes aquello de lo que depende puede producir un estado que ninguna secuencia legítima habría alcanzado. El caso clásico es el conjunto con borrados: un delta que representa el borrado de un elemento debe llegar acompañado del conocimiento de las altas que borra, o el receptor no sabrá que eran las mismas.

La condición correcta se enuncia con precisión: la secuencia de deltas que una réplica recibe de un par debe formar un intervalo causalmente consistente, es decir, un tramo contiguo de la historia de ese par sin huecos. No hace falta orden global, ni difusión causal entre todas las réplicas: basta con que cada par a par no se salte fragmentos. Eso se consigue numerando los deltas por emisor y llevando la cuenta de hasta dónde ha reconocido cada destinatario, que es una tabla pequeña y local.

// Numeracion por emisor y tabla de reconocidos: toda la contabilidad necesaria
let siguiente = 0;
const almacen  = new Map();  // numero de secuencia a delta
const acuse    = new Map();  // destinatario a ultimo numero reconocido

function emitir(d) {
  almacen.set(siguiente++, d);
}

function tramoPara(destino) {
  const desde = (acuse.get(destino) ?? -1) + 1;
  if (!almacen.has(desde)) return { tipo: "estado", cuerpo: estado }; // hueco: mandar todo
  const trozos = [...almacen].filter(([n]) => n >= desde).map(([, d]) => d);
  return { tipo: "tramo", desde, cuerpo: trozos.reduce(mezclar, {}) };
}

Observa la rama que devuelve el estado completo cuando el delta más antiguo que el destinatario necesita ya fue descartado del almacén. No es un caso de error ni una degradación: es la política de retención funcionando como se diseñó. Guardar deltas indefinidamente para un destinatario que puede no volver nunca sería la peor decisión posible, y la existencia de una salida siempre correcta permite fijar la retención por criterios de espacio y no por miedo.

🧩

Contiguo por emisor

Cada par recibe un tramo sin huecos del historial del otro. No se exige nada entre pares distintos.

🔢

Numeración local

Un contador por emisor y una tabla de reconocidos bastan. No hay vectores globales ni coordinación.

♻️

El duplicado sigue siendo gratis

Reenviar un tramo ya recibido no daña. La única obligación es no dejar agujeros, no evitar repeticiones.

📚

Estado completo como comodín

El estado entero es siempre un intervalo válido. Enviarlo repara cualquier hueco sin lógica adicional.

💡
El estado completo no es el plan B, es el plan de reparación

La propiedad más útil del diseño delta en producción es que nunca deja al sistema atrapado. Si una réplica detecta que le falta un tramo que el emisor ya no conserva, si aparece un par completamente nuevo, si un cliente restaura una copia antigua o si simplemente la tabla de reconocimientos se pierde, la respuesta es siempre la misma y no requiere ningún código especial: enviar el estado completo. Es un intervalo causalmente consistente por vacuidad y la mezcla lo acepta sin distinguirlo de un delta. Compáralo con lo que costaba la misma reparación en la familia basada en operaciones, donde un hueco definitivo bloqueaba el búfer causal indefinidamente y la salida exigía un mecanismo distinto del habitual. Aquí el camino de reparación y el camino normal son literalmente la misma función.

Lo que se conserva y lo que sigue costando

El balance final explica por qué esta idea reordenó el área. Se conserva el contrato de red barato en todo lo esencial: los duplicados siguen siendo inocuos, el orden entre pares distintos sigue siendo irrelevante y no hace falta difusión causal global. Se conserva la demostración de convergencia, que es la misma de siempre porque el objeto que viaja es del mismo tipo. Y se gana lo único que faltaba: el mensaje mide lo que mide la edición, con lo que desaparece el argumento que hacía inviable a la familia para documentos grandes.

Lo que se paga es contabilidad, y conviene tenerla presente antes de darla por gratuita. Hay que numerar los deltas por emisor y conservarlos hasta que todos los destinatarios conocidos los hayan reconocido, lo que implica un búfer de salida con política de retención y una decisión sobre qué hacer con un destinatario que lleva semanas sin aparecer. Hay que llevar la tabla de reconocimientos, que crece con el número de pares. Y hay que decidir el umbral a partir del cual conviene mandar el estado completo en vez de un tramo largo, porque llega un punto en que el tramo pesa más que el objeto.

El delta no es una optimización del transporte: es el descubrimiento de que el estado ya tenía estructura interna

Vale la pena mirar esta técnica desde arriba, porque su importancia no está en el ahorro de bytes sino en lo que revela sobre el objeto con el que estábamos trabajando. La formulación original tratable el estado como un átomo: una réplica lo tiene entero o no lo tiene, lo manda entero o no manda nada, y la única estructura reconocida era el orden parcial entre estados. El delta-mutador parte de una observación distinta: si el estado es un elemento de un semirretículo, entonces está descompuesto de antemano en piezas mezclables, porque la mezcla es lo que lo compone y la composición siempre admite lectura inversa. Un estado no es un valor indivisible que casualmente se puede unir a otro; es la unión de todos los fragmentos que lo formaron, y esos fragmentos siguen siendo elementos legítimos del mismo conjunto. La técnica no añade estructura al problema: descubre la que ya estaba y decide usarla para transmitir. Fíjate en la consecuencia sobre el eje que ordena las dos familias. Con deltas, el mensaje tiene el tamaño de una operación y el álgebra de un estado, de modo que el compromiso que la lección anterior presentaba como una elección entre dos extremos deja de ser una elección entre dos puntos y pasa a ser un parámetro continuo: cuánta acumulación local se hace antes de emitir, y en el límite superior de ese parámetro el sistema envía el estado completo y en el inferior envía cada edición. Un sistema delta bien construido recorre ese continuo en tiempo de ejecución sin cambiar de código, mandando fragmentos diminutos durante una sesión activa y el objeto entero cuando alguien reaparece tras un mes. Y ahí está la lección de diseño que trasciende a los CRDT y que conviene llevarse: cuando un compromiso entre dos alternativas parece irreductible, merece la pena preguntarse si las dos alternativas son puntos extremos de un mismo espacio parametrizado que nadie ha nombrado todavía. En este caso lo eran, el parámetro era la granularidad de la unidad transmitida, y hacía falta la observación de que la unidad pequeña podía pertenecer al mismo tipo que la grande para que el espacio entero se volviera visible.

⚔️ Deltifica un tipo y verifica sus garantías
  1. Escribe el delta-mutador de tu contador y comprueba con casos concretos la identidad que lo define frente a la actualización original.
  2. Implementa la acumulación en delta-grupos con vaciado por tiempo y por tamaño, y mide el número de mensajes frente a la versión sin acumulación.
  3. Somete el resultado a un canal que duplique y reordene, y verifica que el estado final coincide con el del envío perfecto.
  4. Elimina deliberadamente un delta intermedio en un tipo con borrados y localiza el estado incorrecto que aparece por la ruptura del intervalo.
  5. Añade numeración por emisor y tabla de reconocimientos, y define la política de retención del búfer de salida para un par ausente durante un mes.
  6. Determina empíricamente el umbral de tamaño a partir del cual enviar el estado completo sale más barato que el tramo pendiente.