Lápidas: el coste que arrastra borrar
Una lápida es el registro de que algo dejó de existir, es inevitable porque la ausencia no se puede representar con ausencia, y su acumulación es la factura que arrastra cualquier CRDT que permita borrar.
Las cuatro lecciones anteriores han recorrido el mismo camino desde cuatro ángulos y siempre han terminado en el mismo sitio. El conjunto que solo crece no permite borrar. Añadirle un borrado ingenuo destruye la convergencia. El conjunto de dos fases lo consigue guardando lo eliminado, y muere porque no permite volver. El conjunto de observaciones y eliminaciones lo consigue bien, y guarda etiquetas. En los tres casos que sí funcionan hay un residuo que no se va: un dato cuyo único contenido es que algo ya no está. Se llama lápida, se puede comprimir muchísimo, se puede resumir con vectores, se puede fusionar por tramos, y no se puede eliminar sin permiso de todo el mundo. Esta lección cierra el nivel poniendo cifras a ese residuo, demostrando que su existencia no es un defecto de implementación sino un resultado sobre la información, y explicando por qué la única forma conocida de librarse de él reintroduce, en dosis pequeñas, la coordinación que local-first había retirado.
- Definir con precisión qué es una lápida y reconocer sus cuatro formas en las estructuras ya vistas.
- Demostrar que ninguna réplica puede podar por su cuenta sin arriesgarse a una resurrección.
- Medir el coste real en almacenamiento, en tiempo de lectura y en la sincronización inicial.
- Entender qué condición hace segura la poda y por qué esa condición es el objeto del nivel 44.
Cuatro formas de la misma cosa
Una lápida es cualquier dato que se conserva únicamente para impedir que un hecho ya retirado vuelva a considerarse vigente. No aporta valor al usuario, no aparece en ninguna consulta y no se puede leer como contenido: existe para decir que no. Vale la pena inventariar las formas que ya han pasado por delante, porque son la misma idea con costes muy distintos y la diferencia entre ellas es la primera palanca disponible.
El valor completo
El conjunto de bajas del conjunto de dos fases guarda el elemento entero. Es la forma más cara posible: la lápida pesa lo mismo que el dato al que sustituye.
La etiqueta suelta
El registro de etiquetas retiradas guarda solo un identificador por adición eliminada. Mucho más barato, pero crece con el número de operaciones.
El vector resumido
Un vector de versiones describe con una entrada por réplica un número ilimitado de etiquetas retiradas. Pasa de crecer con las operaciones a crecer con los dispositivos.
El tramo fusionado
En una secuencia, mil caracteres borrados seguidos son un solo intervalo. Es la técnica que hace viables los editores de texto colaborativos.
La progresión de esa lista es la historia de la ingeniería de esta disciplina de los últimos quince años, y conviene leerla como lo que es: una secuencia de compresiones cada vez mejores sobre un objeto que nunca llega a desaparecer. Ninguna de las cuatro formas elimina la lápida; las cuatro la codifican mejor. Que Automerge redujera su consumo de memoria en torno a un orden de magnitud con la versión 3.0 a finales de 2025, o que Yjs fusione elementos borrados contiguos desde hace años, son victorias reales de codificación, y no cambian en nada el resultado de la sección siguiente.
Por qué ninguna réplica puede podar sola
La inevitabilidad se demuestra en dos pasos y el primero ya está hecho. La lección segunda estableció que un estado que olvida no puede distinguir lo que nunca existió de lo que fue retirado, y que por tanto la retirada tiene que dejar un rastro. Eso obliga a crear la lápida. El segundo paso es el que obliga a conservarla, y es igual de corto: en el momento en que una réplica la borra, su estado vuelve a ser indistinguible del de una réplica que jamás conoció el elemento, y a partir de ahí cualquier interlocutor que todavía tenga el alta la resucitará en la siguiente mezcla.
const conLapida = { altas: new Set(["A:1"]), muertas: new Set(["A:1"]) };
const podado = { altas: new Set(["A:1"]), muertas: new Set() };
const rezagada = { altas: new Set(["A:1"]), muertas: new Set() };
const unir = (x, y) => ({
altas: new Set([...x.altas, ...y.altas]),
muertas: new Set([...x.muertas, ...y.muertas]),
});
unir(conLapida, rezagada).muertas.has("A:1"); // true: sigue muerta
unir(podado, rezagada).muertas.has("A:1"); // false: ha resucitado
De ahí se sigue la condición exacta que hace segura la poda, y merece enunciarse con cuidado porque es el resultado central del nivel: una lápida se puede eliminar cuando se sabe que todas las réplicas que podrían contradecirla ya la han visto. Ese estado tiene nombre propio en la literatura de sistemas distribuidos, estabilidad causal, y se comprueba con la misma herramienta del nivel 22: una operación es estable cuando el vector de versiones de todas las réplicas conocidas la cubre.
// Podar es seguro solo cuando nadie que quede puede desconocerla
function podable(etiqueta, vectores) {
const [replica, n] = etiqueta.split(":");
return vectores.every((v) => (v[replica] ?? 0) >= Number(n));
}
const conocidos = [{ A: 9, B: 4 }, { A: 7, B: 4 }, { A: 9, B: 2 }];
podable("A:7", conocidos); // true: las tres la han visto
podable("A:8", conocidos); // false: una replica se ha quedado atras
podable("B:3", conocidos); // false: la tercera solo va por B dos
Lee con atención lo que exige esa función, porque ahí está el precio escondido de todo el nivel. Necesita la lista completa de réplicas que existen, lo cual presupone un registro de pertenencia que alguien mantiene. Necesita haber recibido noticias recientes de todas ellas, lo cual presupone que todas siguen vivas y hablando. Y basta un solo dispositivo apagado en un cajón para que el mínimo se congele y la poda se detenga por completo, indefinidamente, para todo el sistema. Un portátil olvidado durante un año no ralentiza la limpieza: la bloquea.
La salida a ese bloqueo es siempre la misma y nunca es agradable: declarar que una réplica que lleva demasiado tiempo callada deja de contar. Eso reduce el problema a elegir un plazo, y en cuanto hay un plazo hay una promesa que se rompe, porque el dispositivo expulsado que reaparece traerá altas cuyas bajas ya no existen y verá resucitar cosas que alguien eliminó hace meses. Ninguna elección de plazo evita el dilema: corto significa perder trabajo real de gente que se fue de vacaciones, largo significa no podar nunca. Y como el plazo determina qué datos vuelven de entre los muertos y en qué circunstancias, no es un parámetro de configuración: es una promesa de producto que alguien debe redactar en lenguaje natural antes de que nadie la escriba en un fichero de constantes.
Cuánto pesa esto de verdad
Los números importan porque la intuición falla en la dirección optimista. El almacenamiento de una estructura con lápidas no depende del tamaño de los datos vivos sino del número de operaciones ejecutadas a lo largo de toda la vida del documento, y esas dos magnitudes divergen sin límite. Un documento que se escribe y se reescribe muchas veces termina siendo mayoritariamente historia de cosas que ya no están.
const bytes = (v) => new TextEncoder().encode(JSON.stringify(v)).length;
const vivos = [];
const lapidas = [];
for (let i = 0; i < 20000; i++) {
if (i % 20 === 0) vivos.push({ id: "A:" + i, ch: "x" }); // sobrevive uno de cada veinte
else lapidas.push({ id: "A:" + i });
}
bytes(vivos); // mil caracteres visibles
bytes(lapidas); // diecinueve mil lapidas
bytes(lapidas) / bytes(vivos); // el estado pesa mas de diez veces el valor
Y no es solo disco. El coste se cobra en tres sitios y el más molesto no es el que se anticipa. En almacenamiento, todo lo anterior. En tiempo de lectura, porque cada consulta tiene que filtrar lo muerto para calcular el valor, de modo que una operación que debería costar en proporción a lo visible acaba costando en proporción a lo que hubo. Y en la sincronización inicial, que es el peor: un dispositivo nuevo descarga toda esa historia antes de poder mostrar nada, y esa descarga es precisamente el primer contacto de un usuario con tu producto.
// Fusionar lapidas contiguas: de una por operacion a una por tramo
function comprimir(lapidas) {
const ids = lapidas.map((l) => Number(l.id.split(":")[1])).sort((a, b) => a - b);
const tramos = [];
for (const n of ids) {
const ultimo = tramos.at(-1);
if (ultimo && ultimo[1] + 1 === n) ultimo[1] = n;
else tramos.push([n, n]);
}
return tramos;
}
comprimir(lapidas).length; // unos mil tramos en lugar de diecinueve mil
bytes(comprimir(lapidas)) < bytes(vivos); // la compresion cambia el orden de magnitud
flowchart LR E[cada retirada deja una lapida] --> G[el estado crece aunque el valor encoja] G --> C[comprimir y resumir bajan el coste sin quitarlo] C --> P[podar exige saber que todas las replicas lo vieron] P --> B[eso es una barrera de estabilidad causal] B --> N[y por eso existe el nivel 44] style G fill:#f9e2af,color:#11111b style B fill:#f38ba8,color:#11111b
Lo que queda para el nivel 44
La conclusión operativa del nivel es que las lápidas tienen dos tratamientos y solo dos, y son de naturaleza distinta. El primero es la codificación, que es local, barato, sin riesgo y con un techo: resumir con vectores, fusionar tramos, comprimir identificadores, guardar solo la referencia y no el valor. Todo eso lo puede hacer una réplica sola, ahora mismo, sin hablar con nadie, y suele bastar durante mucho tiempo. El segundo es la eliminación de verdad, que es global, exige acuerdo y no la puede hacer nadie por su cuenta.
Ese segundo tratamiento es el contenido de los niveles 44 a 48, y ahora se puede anticipar con precisión de qué van a tratar. Van a construir el momento en que un conjunto de réplicas puede afirmar conjuntamente que un tramo de historia es pasado cerrado, sustituirlo por una instantánea que resuma su efecto, y descartar lo que había debajo. Van a direccionar esa instantánea por su contenido, para que dos réplicas que compactaron por separado puedan comprobar que llegaron al mismo sitio sin comparar nada más que un resumen. Y van a organizar la historia como un grafo de Merkle, que es la estructura que permite decir tenemos lo mismo hasta aquí con un intercambio de tamaño constante. Todo eso existe por esta lección: son las herramientas para negociar el olvido en un sistema donde nadie manda.
Conviene terminar el nivel entendiendo qué se compró exactamente y con qué moneda, porque la contabilidad completa solo se ve desde aquí. Un sistema con árbitro central resuelve el borrado de una manera que ahora se puede describir con exactitud: paga coordinación por adelantado, en cada escritura, en forma de una petición que viaja y espera, y a cambio obtiene el derecho a olvidar de inmediato. Cuando el servidor elimina una fila, esa fila deja de existir en el instante, sin residuo, porque no queda nadie con autoridad para sostener lo contrario. La rueda de carga que sufres en cada operación es literalmente el precio de esa limpieza. Local-first hace el trueque inverso y lo hace entero: retira la coordinación de la ruta de escritura —de ahí la latencia cero, el funcionamiento sin red, la autonomía real del dispositivo— y el coste no desaparece, se difiere y cambia de forma. Reaparece como memoria. Cada lápida que tu sistema arrastra es una unidad de acuerdo que no pediste en su momento y que ahora tienes que conservar por si alguien, alguna vez, aparece sin haberse enterado. Visto así, todo el nivel se reordena en una sola frase: no elegiste entre coordinar y no coordinar, elegiste entre coordinar antes de escribir o recordar hasta que todos lo sepan. Y hay una simetría final que conviene tener presente porque es la que gobierna las decisiones de los próximos veinte niveles: la compactación es el punto donde vuelves a pedir un poco de coordinación, deliberadamente, en el momento que a ti te conviene y no cuando el usuario está esperando. Un instante de acuerdo entre réplicas, negociado en segundo plano, compra el derecho a tirar meses de historia. Esa es la operación económica fundamental de todo sistema local-first maduro, y por eso ninguno de los serios es puramente descoordinado: son sistemas que han movido la coordinación desde la ruta crítica hasta un proceso de mantenimiento, y que pagan mientras tanto, en memoria, el interés de haberla aplazado.
- Instrumenta una estructura tuya para reportar por separado el tamaño del valor visible y el del registro de retiradas, y déjalo visible en tu panel de desarrollo.
- Simula un año de uso de tu usuario más intenso y anota la proporción entre ambos números al final del recorrido.
- Implementa la fusión de lápidas contiguas y vuelve a medir. Anota qué porcentaje del ahorro se consigue solo con codificar mejor.
- Escribe la comprobación de estabilidad causal para tu topología real y determina cuántas réplicas activas necesitas para que la poda avance alguna vez.
- Redacta en lenguaje natural el plazo tras el cual expulsas a un dispositivo callado y qué le dirás al usuario cuando reaparezca. Si no puedes redactarlo, aún no puedes podar.