El coste: un vector que crece con cada dispositivo
El vector ocupa una entrada por réplica y en local-first cada dispositivo de cada usuario es una réplica que nunca se da de baja, así que podar deja de ser una optimización y pasa a ser una apuesta.
En la literatura clásica el coste del vector clock aparece como una nota al pie: crece linealmente con el número de procesos, y como los procesos de un sistema distribuido clásico son un puñado de servidores administrados, la nota se queda en nota. En local-first la aritmética cambia de naturaleza. Aquí una réplica no es un servidor: es un portátil, un móvil, un segundo móvil, una tableta que alguien usó dos veces en 2023, la pestaña que un usuario abrió en un ordenador prestado. Cada uno de ellos aporta una entrada permanente a una estructura que se guarda con cada objeto o con cada operación, y ninguno de ellos se da de baja nunca, porque no existe la autoridad que pueda declararlos muertos. Este es el punto donde la teoría bonita de las dos lecciones anteriores se encuentra con el disco del usuario.
- Calcular con números reales el metadato que un vector añade por objeto y por operación.
- Entender por qué el problema no es el tamaño sino la imposibilidad de dar de baja una réplica.
- Comparar las estrategias de reducción y clasificar cuáles atacan la constante y cuáles el crecimiento.
- Adoptar la regla de seguridad que ordena los riesgos: qué error se puede tolerar y cuál no.
La aritmética, con números
Antes de los números, una precisión sobre qué se está contando. Las réplicas de un sistema local-first no son las personas ni las cuentas: son los almacenamientos independientes capaces de originar escrituras. Dos pestañas del mismo navegador comparten almacenamiento y son una sola réplica; el mismo usuario en el móvil y en el portátil son dos; el mismo navegador tras un desalojo de almacenamiento es, a todos los efectos causales, una tercera.
Una entrada son dos cosas: un identificador de réplica y un contador. Con un identificador de dieciséis bytes y un contador de ocho, cada entrada cuesta veinticuatro bytes antes de codificar nada. Un documento compartido por veinte personas con tres dispositivos cada una tiene sesenta réplicas, y por tanto un vector de mil cuatrocientos bytes. Si ese vector es un version vector por documento, mil cuatrocientos bytes es una cifra perfectamente asumible. Si es un vector clock por operación y el documento acumula cincuenta mil operaciones, son setenta megabytes de metadato para un documento cuyo contenido puede que no llegue a cien kilobytes.
Ninguno de esos números es hipotético. Un equipo mediano que comparte un espacio de trabajo durante tres años acumula sin esfuerzo un par de centenares de identidades históricas por documento activo, y la distribución no es uniforme: unos pocos documentos concentran la mayoría de los colaboradores y son justo los que más se abren. El promedio, por tanto, engaña; lo que decide la experiencia es la cola, y la cola es donde el vector duele.
La comparación con el tamaño del dato es lo que suele encender la alarma, y con razón: es fácil llegar a una situación en la que el metadato causal domina el almacenamiento por dos órdenes de magnitud. Pero el coste tiene otras dos dimensiones que se olvidan. La primera es de cómputo: comparar dos vectores es lineal en el número de réplicas y fusionar también, así que una sincronización que compare miles de sellos multiplica ese lineal por el volumen. La segunda es de red, y en un dispositivo móvil con datos medidos es la que el usuario paga literalmente.
flowchart LR U[usuarios del documento] --> D[dispositivos por usuario] D --> R[numero de replicas] R --> V[bytes por vector] V --> O[por objeto o por operacion] O --> M[metadato total en disco y en red] R --> C[coste lineal de comparar y fusionar] style R fill:#f38ba8,color:#11111b style M fill:#f9e2af,color:#11111b
Conviene tener la cuenta escrita en código y no en la cabeza, porque es la clase de número que cambia de orden de magnitud cuando alguien ajusta una decisión aparentemente menor y nadie vuelve a hacer la multiplicación.
const BYTES_ID = 16; // uuid binario
const BYTES_CONTADOR = 8; // entero de 64 bits
function metadatoCausal({ replicas, objetos, operaciones, porOperacion }) {
const porVector = replicas * (BYTES_ID + BYTES_CONTADOR);
const cuantos = porOperacion ? operaciones : objetos;
return { porVector, total: porVector * cuantos };
}
metadatoCausal({ replicas: 60, objetos: 1, operaciones: 50_000, porOperacion: false });
// porVector 1440, total 1440 bytes: irrelevante
metadatoCausal({ replicas: 60, objetos: 1, operaciones: 50_000, porOperacion: true });
// porVector 1440, total 72 000 000 bytes: el metadato es el producto
Y hay un multiplicador silencioso que aparece en cuanto miras el histórico real de un producto. Las réplicas no se corresponden con dispositivos vivos sino con dispositivos que alguna vez escribieron. Un usuario que cambia de móvil cada dos años y reinstala la aplicación un par de veces aporta cuatro o cinco identidades a lo largo de la vida del documento. El navegador añade lo suyo: un almacenamiento desalojado significa una identidad nueva la próxima vez que ese usuario vuelva. Ninguna de esas identidades desaparece del vector por sí sola.
Lo que impide dar de baja
Aquí está el nudo, y no es de ingeniería. Para eliminar una entrada de un vector sin romper nada necesitas saber que esa réplica no volverá a aparecer con eventos antiguos que ya no puedas ordenar. Y esa pregunta no se puede contestar. Una réplica silenciosa es indistinguible de una réplica lenta: el portátil que lleva ocho meses sin sincronizar puede estar en la basura o puede estar en un cajón, y el día que alguien lo encienda va a producir escrituras cuyo pasado causal habías decidido olvidar.
La formulación clásica de la única poda segura se llama estabilidad causal: una operación es causalmente estable cuando todas las réplicas la han visto, y a partir de ese momento nada de lo que llegue en el futuro podrá ser concurrente con ella, de modo que su metadato causal deja de hacer falta. Es correcta y es preciosa, y en local-first casi nunca es aplicable, porque exige dos cosas que no tienes: conocer el censo completo de réplicas y recibir señal de todas ellas. La réplica más lenta gobierna la frontera, y una réplica que no vuelve la congela para siempre.
flowchart LR R1[replica activa] --> B[frontera de estabilidad causal] R2[replica activa] --> B R3[replica dormida hace ocho meses] --> B B --> P[por debajo se puede podar] B --> N[por encima hay que conservarlo todo] R3 --> F[una sola replica lenta congela la frontera] style R3 fill:#f38ba8,color:#11111b style F fill:#f38ba8,color:#11111b style P fill:#a6e3a1,color:#11111b
Si pudieras enumerar con certeza las réplicas vivas, casi todas las estrategias de esta lección serían triviales y seguras. Lo que hace difícil el problema es que en local-first no hay ninguna entidad legitimada para mantener ese censo: el servidor, si existe, es un relé de mensajes y no una autoridad, y darle esa autoridad es exactamente el paso que sacaría a tu sistema de la categoría en la que querías estar.
Hay una analogía que ayuda a colocar el problema en su sitio: es el mismo obstáculo que hace imposible la detección perfecta de fallos en un sistema asíncrono. No puedes distinguir un nodo caído de un nodo lento porque la única evidencia que tienes es el silencio, y el silencio es compatible con las dos hipótesis. Lo que en un centro de datos se resuelve a base de plazos agresivos, porque un servidor que no responde en treinta segundos casi siempre está roto, en local-first no admite esa salida: aquí el silencio de meses es comportamiento normal y esperado, no una anomalía.
Estrategias, y lo que cada una cuesta
Conviene separarlas en dos grupos, porque se confunden a menudo. Unas reducen la constante y son seguras; otras atacan el crecimiento y ninguna es gratis.
// Constante: identidades cortas mediante un registro compartido
// El vector viaja como enteros pequenos y el registro se sincroniza aparte
function comprimir(vv, registro) {
const salida = [];
for (const [uuid, n] of Object.entries(vv)) {
salida.push(registro.indice(uuid), n); // dos enteros por entrada
}
return salida; // ordenar por indice permite ademas codificar diferencias
}
La distinción entre los dos grupos importa porque determina quién puede aprobar el cambio. Las reducciones de constante son decisiones de representación: se pueden revisar, medir y revertir, y su peor consecuencia es que el ahorro sea menor del esperado. Las reducciones de crecimiento son decisiones de semántica: cambian lo que el sistema es capaz de distinguir, no se pueden revertir sobre los datos ya podados y su peor consecuencia le ocurre a un usuario, no a una métrica. Merecen escrutinio distinto y, en un equipo, aprobación distinta.
Sustituir identificadores largos por índices de un registro compartido divide el tamaño por tres o por cuatro y no pierde ninguna distinción causal. Codificar por diferencias sobre las entradas ordenadas, con enteros de longitud variable, vuelve a dividir, porque los vectores de un mismo documento se parecen mucho entre sí. Compartir el vector entre operaciones vecinas —guardarlo una vez por lote en lugar de una vez por operación— es la palanca más potente de todas cuando el vector era por evento, y tampoco pierde nada mientras el lote sea causalmente homogéneo. Estas tres se aplican siempre y no hay debate.
Poda por estabilidad causal
Segura por construcción, pero requiere el censo y la señal de todos. Úsala si tu topología tiene un conjunto pequeño y conocido de réplicas que siempre vuelven.
Caducidad por inactividad
Declaras muerta una réplica tras un plazo. Barata y efectiva; el riesgo es el dispositivo que vuelve, cuyas escrituras aparecerán con un pasado que ya nadie recuerda.
Una identidad por usuario
Reduce las réplicas de dispositivos a personas. Solo es válida si un usuario no escribe desde dos dispositivos sin conexión a la vez, cosa que sí ocurre.
Instantánea y corte
Materializas el estado, sellas el corte y descartas la historia anterior. Elimina el metadato de golpe a cambio de perder el deshacer y la fusión fina por debajo del corte.
Del segundo grupo, la que mejor relación tiene entre riesgo y beneficio en la mayoría de los productos reales es la instantánea con corte, y merece un párrafo propio porque su riesgo es el más fácil de comunicar. Materializas el estado del documento en un punto, sellas ese punto con el vector correspondiente y descartas todo lo anterior. Cualquier réplica que vuelva con eventos por debajo del corte recibe el estado materializado en lugar de la fusión fina, lo cual es una degradación honesta y explicable: no pierdes datos, pierdes la capacidad de entrelazar cambios antiguos con precisión. Es la misma operación que hace cualquier sistema de registro cuando compacta, y tiene la ventaja de que el momento del corte lo eliges tú y puedes anunciarlo.
Antes de tocar ninguna política con riesgo, agota las eliminaciones que no pierden nada. Un vector clock por operación se puede sustituir por el sello del lote más el número de secuencia dentro del lote; las entradas de réplicas cuyo contador ya está cubierto por un corte materializado se pueden colapsar en una sola marca de base; y un documento que solo ha visto tres identidades no necesita reservar espacio para las sesenta que el esquema permite. Estas reducciones son puramente de representación y ninguna cambia lo que el sistema puede distinguir.
Sobre la caducidad conviene ser muy explícito, porque es la que más se usa y la que peor se documenta. Si podas la entrada de una réplica y esa réplica reaparece con su contador en ciento cuarenta, tu vector la lee desde cero y concluye que esas ciento cuarenta operaciones son novedades. En el mejor caso reaplicas cosas que ya estaban y el resultado converge igual porque tus operaciones son idempotentes. En el peor, un contador que reinició tras un desalojo de almacenamiento produce eventos nuevos con números viejos y el filtro de duplicados los descarta para siempre. La mitigación es sencilla y hay que escribirla el primer día: una réplica que pierde su estado nunca reutiliza su identidad, genera una nueva.
La regla de seguridad
Todas las estrategias del apartado anterior pueden equivocarse, y sus errores no son equivalentes. Hay dos formas de fallar y una es mucho peor que la otra, hasta el punto de que ordenar bien esta jerarquía vale más que cualquier optimización de tamaño que puedas hacer.
El primer fallo es la concurrencia falsa: has perdido información causal y el sistema cree que dos eventos son independientes cuando en realidad uno venía del otro. El resultado es un conflicto que no existía, que en el peor caso llega hasta el usuario en forma de dos versiones de un documento y una pregunta molesta. Es ruido, es coste, es una mala experiencia, y no se pierde ni un dato.
El segundo fallo es la causalidad falsa: el sistema cree que ya vio algo que no ha visto, o que un evento precede a otro cuando eran independientes. El resultado es que una escritura se descarta en silencio y nadie se entera nunca, porque no hay ninguna señal que emitir cuando el sistema está convencido de que no había nada que hacer. Ese es el fallo que destruye la confianza en un producto local-first, y es el único que no se puede reparar después, porque el dato ya no está en ninguna parte.
La asimetría se traduce directamente en cómo se escribe cada política. Cuando una poda te obligue a elegir entre asumir que dos eventos son concurrentes o asumir que uno precede al otro, elige siempre lo primero. Cuando dudes entre descartar un evento por parecerte ya visto o aplicarlo de nuevo, elige aplicarlo de nuevo, y paga por adelantado el precio de que tus operaciones sean idempotentes para que ese reintento sea inofensivo. La idempotencia deja de ser una virtud opcional en el momento en que decides podar: es lo que convierte el error tolerable en un error sin consecuencias.
La operación mental que hay que hacer aquí, y que separa a quien mantiene un sistema de sincronización de quien solo lo integra, es dejar de leer la poda como una técnica y empezar a leerla como una hipótesis sobre el comportamiento de personas reales. Cuando eliges caducar una réplica a los noventa días no estás ajustando una constante: estás afirmando que ningún usuario tuyo va a encender un dispositivo dormido tres meses después con cambios sin sincronizar, y esa afirmación es empírica, es comprobable y en cualquier base de usuarios suficientemente grande es falsa. Lo mismo vale para la identidad por usuario, que afirma que nadie edita desde el móvil en el avión y desde el portátil en casa el mismo fin de semana, o para la instantánea con corte, que afirma que nadie va a querer deshacer por debajo de la línea que trazaste. Ninguna de esas apuestas es ilegítima; lo ilegítimo es hacerlas sin enunciarlas, porque entonces no hay forma de saber cuándo el mundo dejó de cumplirlas ni de medir el daño. La disciplina consiste en escribir cada hipótesis en el código, junto al parámetro que la encarna, con la consecuencia esperada cuando falle y, si es posible, con un contador que detecte que falló: cuántos eventos llegaron de réplicas podadas, cuántas veces un identificador reapareció, cuántos conflictos se resolvieron con información incompleta. Y hay una asimetría que ordena todas estas decisiones y que conviene tatuarse antes de tocar el primer parámetro: ante la duda, equivócate siempre en la dirección de la concurrencia falsa y jamás en la de la causalidad falsa. Un conflicto inventado es una molestia visible que el usuario puede resolver; una escritura descartada en silencio es una pérdida invisible que nadie podrá resolver nunca, y basta con que ocurra una sola vez y se descubra para que el usuario deje de confiar en que sus datos están donde cree que están. Ese es, al final, el único activo que un producto local-first tiene.
- Instrumenta tu aplicación para registrar el número de identidades de réplica distintas por documento y publica la distribución real, no la media.
- Calcula el metadato causal en bytes por documento y ponlo al lado del tamaño del contenido: decide con esa razón, no con la intuición.
- Aplica las tres reducciones seguras —registro de identidades, codificación por diferencias y vector por lote— y mide la ganancia de cada una por separado.
- Escribe la hipótesis de tu política de poda en una frase falsable y añade el contador que detecta cuándo se incumple.
- Simula el dispositivo que vuelve después del plazo de caducidad y comprueba, con datos, si tu sistema produce concurrencia falsa o causalidad falsa.
- Garantiza en código que una réplica que pierde su almacenamiento genera una identidad nueva y nunca reutiliza la anterior.