wandres.dev
EL HISTORIAL INFINITO · compactar sin romper

Cuándo se puede tirar una lápida

Una lápida solo puede retirarse cuando todas las réplicas la han visto, y en local-first no existe ni la lista de réplicas ni la garantía de que la que lleva seis meses apagada no vuelva a encenderse.

⏱ 19 min

La lección anterior terminó con una afirmación que ahora hay que convertir en una condición comprobable: una lápida no sobra por ser vieja, sobra cuando nadie puede contradecirla. Esa formulación tiene una traducción técnica exacta, se llama estabilidad causal y se comprueba con la misma maquinaria de vectores de versiones que el nivel 22 dejó construida. Hasta ahí el asunto parece resuelto y de hecho lo está, en el sentido en que lo están los teoremas. El problema empieza cuando se lee con cuidado qué información exige esa comprobación para dar un resultado válido, porque exige dos cosas que un sistema local-first, por definición, ha renunciado a tener: un censo completo de quién participa y noticias recientes de todos ellos. La condición es correcta, es sencilla y es la única segura. Y en el escenario para el que se diseñó todo el modelo, casi nunca se cumple.

🎯 Al terminar esta lección sabrás
  • Enunciar la condición de estabilidad causal con precisión y saber implementar su comprobación.
  • Entender por qué la comprobación necesita el conjunto completo de réplicas y no una muestra.
  • Reconocer las dos formas en que el conjunto de participantes se vuelve desconocido en local-first.
  • Evaluar las aproximaciones habituales y saber exactamente qué garantía sacrifica cada una.

La condición exacta y su comprobación

La regla no admite matices y conviene enunciarla en su forma más fuerte antes de debilitarla. Una operación es causalmente estable cuando toda réplica del sistema la ha recibido. Una vez alcanzado ese estado, ninguna réplica puede emitir en el futuro una operación concurrente con ella, porque cualquier cosa que emitan desde ahora tendrá esa operación en su pasado causal. Y si nada futuro puede ser concurrente con la operación, entonces la prueba de que ocurrió deja de ser necesaria para resolver conflictos: ya no queda ningún conflicto que resolver contra ella.

La comprobación es directa con vectores de versiones. Cada réplica conserva el vector que resume lo que ha visto, esos vectores se intercambian junto con los datos, y una operación etiquetada como el número doce de la réplica A es estable cuando la entrada correspondiente a A en el vector de cada participante conocido vale doce o más. Es decir, se toma el mínimo por componentes de todos los vectores y se declara estable todo lo que queda por debajo de esa frontera.

// Frontera de estabilidad: el minimo por componentes de todos los vectores
function frontera(vectores) {
  const claves = new Set(vectores.flatMap(Object.keys));
  const min = {};
  for (const k of claves) min[k] = Math.min(...vectores.map((v) => v[k] ?? 0));
  return min;
}

const vistos = [{ A: 9, B: 4, C: 7 }, { A: 7, B: 4, C: 7 }, { A: 9, B: 2, C: 7 }];
frontera(vistos);            // A siete, B dos, C siete

const estable = (op, f) => (f[op.replica] ?? 0) >= op.seq;
estable({ replica: "A", seq: 7 }, frontera(vistos));   // true
estable({ replica: "B", seq: 3 }, frontera(vistos));   // false

Esa frontera tiene una propiedad muy conveniente que merece señalarse porque es lo que hace practicable el mecanismo: avanza de forma monótona y no requiere que nadie vote. Nadie tiene que decir estoy de acuerdo en podar; basta con que cada réplica siga informando de lo que ha visto, cosa que ya hace para sincronizar. La coordinación que la compactación reintroduce no tiene forma de consenso ni de bloqueo: tiene forma de acuse de recibo acumulado.

Conviene además separar dos preguntas que se confunden con facilidad porque suenan parecidas. Una es si una operación es estable, que se responde con la frontera y que es una propiedad del sistema entero. La otra es si una réplica concreta puede aplicar una poda, que depende además de que esa réplica conozca la frontera, cosa que solo ocurre si ha recibido los vectores de todos. Dos réplicas pueden estar en desacuerdo sobre lo segundo sin estar en desacuerdo sobre lo primero, simplemente porque una tiene información más fresca que la otra. Esa distinción importa mucho al implementar, porque hace que la poda sea naturalmente asíncrona: cada réplica poda cuando puede, hasta donde puede, y el resultado converge sin que nadie coordine el momento.

ℹ️
Estabilidad causal no es lo mismo que consenso

Conviene no importar aquí la intuición de los sistemas replicados con líder, porque induce a pensar que hace falta algo mucho más caro de lo que hace falta. No hay que decidir nada por mayoría, no hay que elegir entre valores en competencia y no hay ninguna propuesta que pueda ser rechazada. La frontera de estabilidad es una observación, no una decisión: se limita a constatar un hecho sobre lo que ya ha ocurrido en la red. Por eso puede calcularse de forma perezosa, en segundo plano, con información que llega tarde y desordenada, sin comprometer nada mientras se calcula. Todo el coste está en un sitio distinto del que sugiere la palabra coordinación: no está en llegar a un acuerdo, está en saber a quién hay que preguntar.

El censo que no existe

La función anterior recibe una lista de vectores y ahí está enterrado todo el problema del nivel. Para que su resultado sea seguro, esa lista tiene que contener a todos los participantes que existen, no a todos los que conoces. Si falta uno, la frontera calculada está por delante de la frontera real, se declaran estables operaciones que alguien no ha visto y la poda resultante produce resurrecciones cuando ese alguien reaparece. Y falta de un modo particularmente traicionero: el resultado sigue pareciendo correcto hasta meses después.

En un sistema con autoridad central el censo es trivial porque hay una tabla de sesiones y alguien la mantiene. En local-first no lo hay, y su ausencia adopta dos formas distintas que conviene separar porque se mitigan de manera diferente. La primera es la pertenencia dinámica: un usuario añade un portátil nuevo estando sin cobertura, ese dispositivo emite operaciones válidas y ningún otro sabe todavía que existe. La segunda es la pertenencia difusa: en un documento compartido por enlace, cualquiera que lo abrió alguna vez tiene una copia, y no hay ningún registro fiable de cuántas copias hay ni de cuáles siguen importando.

La segunda forma es la más difícil de las dos y merece una precisión, porque suele descartarse pensando que basta con llevar una lista de quién ha accedido. El problema no es registrar accesos, que es fácil, sino que la lista de quién ha accedido no es la lista de quién tiene una copia viva: un navegador puede haber vaciado su almacenamiento, un usuario puede haber sido expulsado del documento conservando lo que ya tenía en disco, y un mismo participante puede figurar una vez y tener cuatro dispositivos. Un censo construido sobre permisos mide autorización y no presencia de datos, y usar el primero como aproximación del segundo produce fronteras optimistas justo en la dirección que causa corrupción.

// Un participante desconocido invalida la frontera sin avisar
const conocidos   = [{ A: 9, B: 4 }, { A: 9, B: 4 }];
const desconocido = { A: 3, B: 4 };   // portatil configurado sin cobertura

frontera(conocidos);                          // A nueve: parece seguro podar A cuatro
frontera([...conocidos, desconocido]);        // A tres: no lo era

// El error no se manifiesta al podar, sino meses despues, al reaparecer

Hay además una asimetría desagradable entre los dos tipos de error posibles y conviene tenerla presente al elegir parámetros. Subestimar la frontera es conservador: se poda menos de lo que se podría, el sistema sigue creciendo, nadie se entera. Sobrestimarla es corrupción silenciosa: los datos se pierden o resucitan en un momento futuro indeterminado, en el dispositivo de un usuario concreto, sin traza que permita reconstruir qué pasó. No son dos caras del mismo error, son un inconveniente y un incidente.

Esa asimetría debería gobernar el diseño de la implementación entera, y con frecuencia no lo hace porque el error caro no aparece en ninguna prueba. Un banco de pruebas normal levanta tres réplicas, las sincroniza, poda y comprueba que todo converge. Pasa siempre, porque el conjunto de réplicas de la prueba es exactamente el conjunto conocido: la condición que falla en producción es justo la que el entorno de prueba garantiza por construcción. Para que el fallo aparezca hay que escribir deliberadamente el caso que a nadie se le ocurre escribir, que es crear una cuarta réplica, desconectarla antes de que las otras sepan de ella y reconectarla después de la poda.

// El caso que hay que escribir a proposito porque nunca sale solo
const red = crearRed(["A", "B", "C"]);
const oculta = crearReplica("D", { desdeSnapshot: red.estadoInicial });

red.sincronizar();                 // A, B y C creen conocer a todos
oculta.editar("cambio no visto");  // D trabaja sin que nadie lo sepa
red.podar(frontera(red.vectores)); // poda con censo incompleto

red.integrar(oculta.pendientes()); // aqui aparece el fallo real
flowchart TB
V[cada replica publica lo que ha visto] --> M[minimo por componentes]
M --> F[frontera de estabilidad causal]
F --> P[por debajo de la frontera se puede podar]
C[censo completo de replicas] --> M
D[replica desconocida o dormida] -.-> C
D --> B[la frontera real esta mas atras de lo que crees]
B --> R[poda insegura y resurrecciones futuras]
style F fill:#a6e3a1,color:#11111b
style R fill:#f38ba8,color:#11111b

El dispositivo que lleva seis meses apagado

Supongamos resuelto el censo, que ya es mucho suponer. Queda el obstáculo que hace inservible la condición en la práctica, y es que la frontera se calcula con un mínimo. Un mínimo lo gobierna su peor elemento, de modo que basta una sola réplica que no informe para que la frontera se congele en el punto donde esa réplica se quedó, y se congele para todo el sistema. No lo ralentiza: lo detiene por completo, indefinidamente.

La aritmética es brutal y merece la pena verla enunciada. Cien dispositivos activos que sincronizan cada hora no consiguen podar nada si el número ciento uno lleva un semestre en un cajón. La utilidad de toda la maquinaria queda condicionada al comportamiento del participante menos comprometido, y en un producto real ese participante siempre existe: el teléfono que se cambió, el portátil del trabajo anterior, la tableta del hijo, la sesión del navegador en un ordenador prestado.

Nótese que este comportamiento es la cara amarga de una propiedad que en otros contextos se celebra. Que un mínimo gobierne el conjunto es exactamente lo que hace segura la condición: garantiza que nadie se queda fuera, que ninguna decisión se toma a espaldas de un participante y que el sistema nunca avanza más allá del más lento. Es la misma clase de garantía que hace que un sistema local-first no pueda dejar tirado a quien está sin cobertura. Aquí, aplicada al mantenimiento en lugar de a la escritura, esa misma garantía se convierte en parálisis, y no hay forma de conservarla para una cosa y suspenderla para la otra porque es literalmente el mismo mecanismo.

Conviene además notar que la probabilidad de que exista al menos un rezagado no se mantiene constante sino que crece con el tamaño de la población, y lo hace rápido. Si cada dispositivo tiene, digamos, un noventa y nueve por ciento de probabilidad de haber sincronizado en el último mes, un documento con cinco dispositivos poda casi siempre y uno con quinientos no poda casi nunca. La compactación, por tanto, funciona peor exactamente en los despliegues donde más falta hace, y esa relación inversa entre necesidad y viabilidad es una de las cosas que más sorprende al pasar de la prueba de concepto al producto.

Y aquí es donde el problema deja de ser técnico. El único modo de desatascar la frontera es declarar que alguien deja de contar, y esa declaración no la puede tomar un algoritmo porque no es una afirmación sobre el estado de la red sino sobre lo que la aplicación promete a sus usuarios. Elegir el plazo es elegir a partir de qué momento estás dispuesto a perder el trabajo de alguien que se fue de baja, se marchó de viaje o simplemente no abrió la aplicación durante una temporada.

⚠️
Todo plazo que elijas rompe una promesa distinta

No existe la elección buena, solo el reparto del daño, y conviene hacerlo con los ojos abiertos. Un plazo corto, de días o semanas, mantiene el sistema compacto y garantiza que tarde o temprano alguien vuelva de vacaciones con dos semanas de trabajo que el resto ya ha decidido ignorar. Un plazo largo, de meses, protege a ese usuario y significa en la práctica no podar nunca, porque en cualquier población suficientemente grande siempre hay alguien dentro de la ventana. Y la solución intermedia que todo el mundo intenta, que es hacer el plazo configurable, no resuelve nada: traslada la decisión a quien tiene menos información para tomarla y añade la posibilidad de que dos administraciones del mismo documento la configuren distinto. Antes de escribir la constante hay que poder escribir la frase que el usuario leerá cuando reaparezca. Si esa frase no se puede redactar de forma que resulte aceptable, la conclusión no es elegir otro número: es que ese producto no puede podar y tiene que resolver el crecimiento por otra vía.

Aproximaciones y lo que sacrifica cada una

Ninguna implementación real comprueba la condición fuerte, porque en el escenario para el que se diseñó local-first casi nunca se cumple. Lo que hacen es sustituirla por una aproximación y aceptar de forma explícita la clase de fallo que esa aproximación admite. Vale la pena inventariarlas por lo que sacrifican, que es la única dimensión en la que se distinguen de verdad.

⏱️

Plazo de inactividad

Se expulsa del censo a quien lleva demasiado tiempo callado. Sacrifica el trabajo del ausente y convierte un parámetro en una promesa de producto.

🗳️

Quórum de participantes

Basta con que un subconjunto acordado haya visto la operación. Sacrifica la seguridad frente a los que quedan fuera del quórum.

🏛️

Testigo con servidor

Un servicio siempre activo actúa de censo y de marca de agua. Sacrifica la ausencia de infraestructura, que era parte del argumento.

🧾

Épocas explícitas

Se cierra un tramo con nombre y se declara pasado. Sacrifica la incorporación automática de quien no participó en el cierre.

Merece la pena observar qué tienen en común las cuatro, porque revela algo sobre la naturaleza del problema. Ninguna mejora el cálculo de la frontera; las cuatro cambian el conjunto sobre el que se calcula. El plazo lo reduce por tiempo, el quórum por número, el testigo lo sustituye por un delegado siempre presente y las épocas lo congelan en el instante del cierre. Es decir: todas las soluciones conocidas atacan el censo y ninguna ataca la condición, lo cual confirma que la condición no tenía nada malo y que el obstáculo estaba siempre en la entrada.

La cuarta merece un comentario porque es la que estructura las dos lecciones siguientes y la que más se parece a lo que hacen los sistemas maduros. En lugar de preguntar continuamente si algo es podable, se declara de vez en cuando que la historia hasta cierto punto queda cerrada, se produce un artefacto que resume su efecto y se identifica ese artefacto por su contenido. La ventaja no es técnica sino organizativa: convierte una comprobación imposible en un acto fechado y auditable, del que se puede hablar en la interfaz y en la documentación, y que deja un objeto concreto contra el que comparar cuando alguien llega tarde.

La condición segura es correcta, decidible e inútil, y esa es la enseñanza

Merece la pena detenerse en la forma exacta de este callejón porque se repite en toda la disciplina y reconocerlo ahorra años. La estabilidad causal no es una heurística ni una aproximación: es la condición necesaria y suficiente, se demuestra en tres líneas y se comprueba con una función de siete. No hay nada que mejorar en ella. Y sin embargo es inaplicable, no porque sea cara de calcular sino porque su entrada es información que el modelo de sistema ha decidido no tener. Local-first no es un sistema distribuido con un censo difícil de mantener; es un sistema cuya propuesta de valor consiste precisamente en no tener censo, en que un dispositivo pueda unirse sin pedir permiso y desaparecer sin avisar. Pedirle la lista completa de participantes es pedirle que deje de ser lo que es. Ahí está la lección de fondo, y trasciende el problema de las lápidas: cuando una condición correcta requiere información que tu arquitectura ha renunciado a poseer, el problema ya no admite una solución técnica, solo un reparto de daños. Y un reparto de daños es una decisión de producto, no de ingeniería, porque consiste en elegir quién pierde qué y en qué circunstancias. Por eso todas las aproximaciones de la tabla anterior se distinguen únicamente por lo que sacrifican y ninguna por lo que consigue: consiguen exactamente lo mismo. Reconocer esto a tiempo cambia por completo la conversación en un equipo. La pregunta deja de ser cómo calculamos bien la frontera, que no tiene respuesta, y pasa a ser a quién estamos dispuestos a dejar atrás y qué le vamos a decir, que sí la tiene y que además hay que responder antes de escribir una sola línea del compactador. La lección cuarta trata enteramente de esa conversación, y no es casualidad que sea la única del nivel donde no hay código.

⚔️ Calcula tu frontera real
  1. Instrumenta tu sistema para registrar, por cada réplica conocida, la fecha de la última noticia recibida y el vector que traía.
  2. Calcula la frontera de estabilidad con todas ellas y anota cuántas operaciones quedan por debajo. Probablemente sean cero.
  3. Recalcula excluyendo las réplicas sin noticias en treinta días, en noventa y en un año. Anota cuánta historia libera cada umbral.
  4. Cuenta qué porcentaje de tus usuarios tiene al menos un dispositivo que quedaría excluido en cada uno de los tres umbrales.
  5. Redacta el mensaje exacto que verá ese usuario al reaparecer, con el nombre de su dispositivo y qué pasa con su trabajo.
  6. Si no puedes redactar ese mensaje sin que suene a fallo del producto, apunta esa conclusión y llévala a la lección cuarta.