wandres.dev
EL HISTORIAL INFINITO · compactar sin romper

El historial que nunca deja de crecer

Un CRDT que lo conserva todo crece de forma monótona aunque el documento visible no cambie, porque las lápidas, los metadatos de posición y el grafo de cambios se acumulan por separado y ninguno se retira solo.

⏱ 18 min

Los niveles anteriores han ido dejando facturas sin pagar y este es el punto en que llegan todas juntas. El nivel 28 estableció que retirar un elemento deja un residuo permanente porque la ausencia no se puede representar con ausencia. El nivel 32 midió lo que cuesta darle a cada carácter una identidad que sobreviva a la edición concurrente. Y el modelo de operaciones sobre el que se apoya cualquier sincronización incremental guarda además el grafo entero de qué cambio dependió de cuál. Las tres cosas son necesarias por separado, las tres están justificadas por separado y ninguna de las tres dispone de un mecanismo propio de retirada. La consecuencia conviene mirarla de frente antes de buscar remedios: el estado de un CRDT es monótono creciente por construcción, y su tamaño no está acotado por lo que el usuario ve sino por lo que el usuario ha hecho. Un documento de dos páginas que lleva tres años abierto no tiene un problema de escala. Tiene un problema de historia.

🎯 Al terminar esta lección sabrás
  • Separar los tres depósitos que crecen y entender por qué cada uno crece por una razón distinta.
  • Reconocer que la magnitud que gobierna el tamaño es la actividad acumulada y no el contenido vigente.
  • Localizar los cuatro puntos del sistema donde ese crecimiento se cobra, que no coinciden en el tiempo.
  • Entender por qué ninguna réplica puede reducirlo por su cuenta y por qué eso lo convierte en un problema de acuerdo.

Tres depósitos que crecen por razones distintas

El primer error al abordar este problema es tratarlo como una sola cifra. Cuando alguien informa de que su documento ocupa cuarenta megabytes con doscientos kilobytes de texto visible, esa cifra es la suma de tres depósitos que se llenan por mecanismos independientes, responden a palancas distintas y se podarían, si se pudiera, con criterios que no tienen nada que ver entre sí. Confundirlos lleva a optimizar el que menos pesa en tu caso concreto y a concluir que la compactación no sirve.

El primer depósito son las lápidas. Su ley de crecimiento es el número de retiradas ejecutadas a lo largo de la vida del dato, y su permanencia no es opcional: mientras alguien pueda llegar con un alta que la lápida contradice, borrarla equivale a resucitar el elemento. Es el depósito más estudiado y el que mejor se comprime, porque lápidas contiguas se fusionan en tramos y los tramos se resumen con vectores.

El segundo son los metadatos de posición. Aquí la ley es distinta y peor: no crecen con las retiradas sino con las inserciones, incluidas las que siguen vivas. Cada elemento de una secuencia arrastra su identidad global y las referencias que fijan su lugar relativo, y esa contabilidad se paga aunque nadie borre nunca nada. Un usuario que solo añade texto y jamás corrige sigue engordando este depósito a razón de varias veces el tamaño de lo que escribe.

El tercero es el grafo de cambios, y es el que más gente olvida porque no forma parte del tipo de dato sino de la capa de sincronización. Para poder intercambiar solo lo que falta, cada réplica conserva el registro de operaciones con sus dependencias, de modo que dos dispositivos puedan calcular la diferencia sin transferir el estado completo. Ese registro es la razón por la que la sincronización es barata, y su ley de crecimiento es simplemente el número de operaciones jamás emitidas.

La asimetría entre las tres leyes tiene una consecuencia práctica que conviene anticipar, porque decide qué perfil de aplicación sufre antes. Un editor de texto colaborativo llena sobre todo el segundo depósito, porque la unidad de identidad es el carácter y se producen miles por sesión. Una aplicación de listas o de fichas llena sobre todo el primero, porque el ciclo de vida natural de sus elementos es aparecer y desaparecer. Y una aplicación con muchos dispositivos por usuario llena sobre todo el tercero, porque cada uno de ellos emite su propia rama de operaciones que todos los demás tienen que conservar para poder reconciliar. Antes de elegir técnica de compactación hay que saber cuál de los tres domina, y esa respuesta no se puede adivinar desde la arquitectura: hay que medirla.

// La cifra unica esconde tres leyes de crecimiento distintas
const estado = {
  valor:    { visible: 210_000 },        // crece con el contenido vigente
  lapidas:  { retiradas: 780_000 },      // crece con los borrados
  posicion: { elementos: 990_000 },      // crece con las inserciones
  grafo:    { ops: 1_100_000 },          // crece con toda la actividad
};

const pesos = Object.entries(estado).map(([k, v]) => [k, Object.values(v)[0]]);
// ordenar por peso antes de optimizar nada
pesos.sort((a, b) => b[1] - a[1]);
ℹ️
La compresión sube el techo pero no cambia la pendiente

Los tres depósitos admiten codificaciones muy buenas y conviene aplicarlas todas antes de plantearse nada más agresivo, porque son locales, seguras y no exigen hablar con nadie. Fusionar lápidas contiguas convierte mil borrados seguidos en un intervalo. Representar tramos de caracteres consecutivos del mismo autor con un contador de longitud convierte una frase entera en un registro. Codificar el grafo con referencias relativas en lugar de identificadores completos ahorra la mayor parte de sus bytes. Todo eso es real y todo eso funciona. Pero fíjate en lo que ninguna de esas técnicas toca: el número de cosas que hay que representar. La compresión multiplica el estado por una constante menor que uno y deja intacta la función que lo hace crecer. Duplica el tiempo que tardas en tener el problema, y eso es valioso, y no es una solución.

El crecimiento sigue a la actividad, no al contenido

La propiedad que hace incómodo este problema es que las dos magnitudes que uno tendería a identificar están desacopladas. El contenido vigente puede oscilar, encoger o quedarse igual durante años; la actividad acumulada solo puede subir. Y como el estado depende de la segunda, no existe ninguna secuencia de acciones del usuario que reduzca el tamaño de la estructura. Vaciar el documento entero lo agranda, porque vaciarlo es actividad.

Ese desacoplamiento explica por qué el problema aparece siempre en producción y nunca en desarrollo. Durante el desarrollo, el contenido y la actividad son aproximadamente lo mismo: se escribe una vez, se prueba y se descarta. En manos de un usuario real durante dieciocho meses, la relación entre ambas se abre sin límite, y lo hace más rápido precisamente en los documentos que más le importan a esa persona, porque son los que más veces ha revisado.

Vale la pena detenerse en esa última observación porque tiene una forma perversa que conviene enunciar sin adornos: la calidad del trabajo y el coste del almacenamiento están correlacionados positivamente. Un texto escrito de un tirón y jamás revisado se comporta de maravilla. Un texto trabajado durante meses, con párrafos movidos, versiones descartadas y recortes sucesivos, es el que peor se abre y el que más ocupa. El sistema penaliza exactamente el uso que uno querría fomentar, y esa inversión de incentivos no aparece en ninguna métrica agregada porque los usuarios afectados son minoría en número y mayoría en valor.

// Doce meses de un usuario real: el valor se estabiliza, el estado no
let visible = 0, actividad = 0;
for (let mes = 1; mes <= 12; mes++) {
  const escrito = 40_000;
  const borrado = mes <= 3 ? 5_000 : 38_000;   // a partir del cuarto mes, revisa
  visible   += escrito - borrado;
  actividad += escrito + borrado;
}

visible;             // unos 155 mil caracteres: casi plano desde el mes cuatro
actividad;           // unos 900 mil eventos: sigue subiendo cada mes
actividad / visible; // la razon crece de forma indefinida

Hay una comprobación que conviene hacer una vez y que suele resultar reveladora, porque contradice la intuición de forma tajante. Si el estado siguiera al contenido, vaciar el documento debería devolverlo a su tamaño inicial. Como sigue a la actividad, vaciarlo es la acción más cara que existe: convierte todo el contenido vigente en lápidas de golpe y añade una operación por cada elemento retirado. Un usuario que limpia su documento para que ocupe menos consigue exactamente lo contrario, y no tiene forma de saberlo.

// Vaciar el documento no lo encoge: lo agranda de golpe
const antes   = { vivos: 120_000, lapidas: 300_000, ops: 420_000 };
const vaciar  = (e) => ({
  vivos: 0,
  lapidas: e.lapidas + e.vivos,     // todo lo vigente pasa a ser residuo
  ops: e.ops + e.vivos,             // y cada retirada es una operacion mas
});

const despues = vaciar(antes);
despues.vivos;                       // cero contenido visible
despues.ops > antes.ops;             // true: el estado ha crecido
flowchart LR
A[actividad acumulada del usuario] --> L[lapidas del nivel 28]
A --> P[metadatos de posicion del nivel 32]
A --> G[grafo de cambios para sincronizar]
L --> E[estado persistido]
P --> E
G --> E
V[contenido vigente] --> E
E --> R[el estado sigue a la actividad y no al contenido]
style A fill:#f9e2af,color:#11111b
style R fill:#f38ba8,color:#11111b

Los cuatro sitios donde se cobra

El coste no se manifiesta en un solo lugar ni en un solo momento, y esa dispersión es la razón por la que se diagnostica tarde. Cada uno de los cuatro puntos siguientes se percibe como un síntoma diferente, se investiga por separado y rara vez alguien los reúne bajo la misma causa hasta que el sistema ya lleva tiempo desplegado.

💡
Instrumenta ahora aunque no tengas todavía el problema

La medida más rentable de todo este nivel cuesta una tarde y consiste en registrar, junto a cada documento, tres números: bytes de valor vigente, bytes de estado total y número acumulado de operaciones. Con esos tres se puede calcular en cualquier momento la razón entre estado e información útil, y sobre todo se puede dibujar su evolución, que es lo que permite predecir cuándo el problema se vuelve urgente en lugar de descubrirlo el día que ya lo es. Quien no lo instrumenta pronto se encuentra más adelante en una situación característica: sabe que la aplicación va lenta, sospecha que es por acumulación, y no tiene ninguna serie histórica con la que confirmarlo ni con la que estimar cuánto margen le queda. Añadir la instrumentación en ese punto sirve para el futuro y no responde a la única pregunta que importa entonces, que es cuánto tiempo llevas ya así.

🧠

Memoria en reposo

Se paga de forma continua mientras el documento está abierto, porque la estructura viva no puede prescindir de ningún elemento que aún pueda servir de ancla.

Apertura en frío

Se paga en el peor momento, cuando alguien quiere empezar a trabajar, y crece con la historia total en lugar de con el texto que va a ver.

📡

Primer emparejamiento

Un dispositivo nuevo descarga la historia entera antes de mostrar nada, y ese es el primer contacto de un usuario con la promesa de inmediatez.

🔋

Mantenimiento continuo

Cada sincronización recorre estructuras proporcionales a la historia, y eso se traduce en batería y en trabajo del hilo principal.

Merece la pena subrayar que las cuatro casillas no responden a la misma mitigación, y esa es exactamente la razón por la que existen tres técnicas distintas en la lección tercera. Una codificación más compacta alivia la memoria en reposo y no acelera la apertura, porque descomprimir es trabajo añadido. Una instantánea del estado materializado acelera la apertura y no baja un solo byte del estado persistido. Y solo tirar historia de verdad mejora el primer emparejamiento, porque lo que se transfiere es precisamente lo que se conserva.

⚠️
El síntoma que se atribuye siempre a otra cosa

El patrón de diagnóstico erróneo es notablemente consistente entre equipos. La aplicación va bien durante meses, empieza a ir despacio para unos pocos usuarios, y esos usuarios resultan ser los más comprometidos con el producto. Como la lentitud aparece primero en dispositivos modestos y en documentos concretos, se atribuye al hardware, al navegador o a un motor de plantillas. Se optimiza el renderizado, se añaden listas virtualizadas, se mueve trabajo a un worker, y cada una de esas medidas da una mejora que dura hasta que la historia vuelve a crecer lo suficiente. La señal que distingue este problema de cualquier otro es sencilla y hay que buscarla explícitamente: si el tiempo de apertura correlaciona con la antigüedad del documento y no con su tamaño visible, no tienes un problema de rendimiento, tienes un problema de acumulación.

Por qué ninguna réplica puede arreglarlo sola

Llegados aquí la tentación es evidente y hay que desactivarla antes de la lección siguiente. Si el problema es que se guarda información que ya no sirve, que cada réplica identifique lo que ya no sirve y lo tire. El obstáculo no es identificar qué sobra, que suele ser fácil, sino que la definición misma de sobrar depende de información que ninguna réplica tiene: qué sabe todo el mundo. Una lápida no sobra porque sea vieja, sobra cuando nadie puede contradecirla, y eso es una afirmación sobre los demás.

Ahí está el giro que estructura el nivel entero. La compresión es una decisión local sobre cómo representar lo que se conserva y no requiere permiso de nadie. La compactación es una decisión sobre qué se deja de conservar, y para ser segura necesita una afirmación conjunta. El resto del nivel consiste en construir esa afirmación, medir lo que cuesta obtenerla y decidir qué hacer cuando resulta imposible.

Conviene además desactivar de antemano la salida que todo equipo intenta al llegar aquí, que es podar por antigüedad. La idea parece razonable: si una lápida tiene más de un año, seguro que todo el mundo la ha visto. El problema no es que la heurística falle a menudo, porque no falla a menudo; el problema es que cuando falla no lo hace de forma visible. No hay excepción, no hay registro y no hay ningún momento en que el sistema pueda decir aquí me equivoqué. Simplemente, meses después, en un dispositivo concreto, un elemento reaparece o desaparece sin explicación, y para entonces la relación con la poda que lo causó es imposible de establecer. Un fallo que no se puede detectar tampoco se puede acotar, y ese es el motivo por el que la lección siguiente empieza por la condición exacta antes de hablar de aproximaciones.

El historial infinito es el precio de haber retirado al árbitro

Conviene ver de dónde sale realmente este crecimiento, porque no es un defecto de los CRDT sino la forma que adopta una elección arquitectónica anterior a ellos. Un sistema con autoridad central puede olvidar de inmediato por una razón que casi nunca se enuncia: el servidor no necesita recordar que algo se borró porque nadie tiene autoridad para afirmar lo contrario. La eliminación es definitiva porque el único que decide qué existe ya ha decidido. Ese poder de cierre es lo que permite que una fila desaparezca sin residuo y que el tamaño de la base siga al contenido en lugar de a la historia. Local-first retira ese árbitro, y con él retira la capacidad de cerrar nada de forma unilateral. A partir de ese momento cualquier réplica puede aparecer mañana sosteniendo un hecho antiguo, y la única defensa disponible frente a esa posibilidad es haber conservado la prueba de que el hecho fue retirado. El historial no crece porque el algoritmo sea ineficiente: crece porque la evidencia es lo único que sustituye a la autoridad. Formulado así se entiende por qué la compresión nunca resuelve el problema y por qué la compactación tiene la forma que tiene. Comprimir mejor la evidencia no reduce cuánta evidencia necesitas, porque la cantidad la fija la incertidumbre sobre quién puede aparecer, no la codificación. Y la única manera de necesitar menos evidencia es reducir esa incertidumbre, es decir, conseguir que las réplicas afirmen conjuntamente que un tramo del pasado ya está cerrado. Eso es coordinación, exactamente la que se retiró de la ruta de escritura, reintroducida deliberadamente fuera de ella. La compactación no es una optimización dentro del modelo local-first: es el momento en que el modelo admite que necesitaba un poco de lo que había suprimido y negocia el precio con calma, en segundo plano, en lugar de pagarlo con el usuario esperando.

⚔️ Instrumenta los tres depósitos por separado
  1. Añade a tu panel de desarrollo tres contadores independientes: bytes de valor vigente, bytes de lápidas y bytes de grafo de cambios. Una sola cifra no sirve.
  2. Toma tu documento de producción más antiguo y anota la proporción entre los tres. Predice cuál domina antes de mirar y comprueba si acertaste.
  3. Registra el tiempo de apertura de veinte documentos reales junto a su tamaño visible y a su número total de operaciones. Dibuja las dos correlaciones.
  4. Simula dieciocho meses del usuario más activo que tengas y anota en qué mes cada depósito supera al valor vigente.
  5. Aplica solo compresión, sin tirar nada, y vuelve a medir. Calcula cuántos meses de margen te ha comprado y apunta la fecha en que el problema regresa.
  6. Escribe en dos líneas cuál de los cuatro puntos de cobro te duele más hoy, porque esa respuesta determina qué técnica de la lección tercera te conviene.