El problema del borrado: el elemento que vuelve
Quitar un elemento y verlo reaparecer no es un fallo de implementación sino la consecuencia exacta de que la mezcla sea correcta: la monotonía que garantiza la convergencia es la misma que prohíbe el borrado ingenuo.
El síntoma es tan reconocible que casi todo el mundo que ha tocado sincronización lo ha visto al menos una vez: alguien elimina una tarea, un contacto o una etiqueta, la interfaz responde al instante, y un rato después —cuando otro dispositivo despierta, cuando el portátil vuelve del fin de semana— el elemento está otra vez ahí, intacto, como si nadie lo hubiera tocado. La reacción instintiva es buscar la carrera, revisar el orden de los mensajes, añadir un reintento. No hay nada que arreglar en ese código. El elemento vuelve porque la función de mezcla hizo con absoluta precisión lo que su definición exige, y la definición no se puede debilitar sin perder la convergencia entera. Esta lección demuestra que la resurrección no es un accidente de implementación sino un teorema, y establece qué hace falta de verdad para que borrar sea una operación expresable.
- Reproducir la resurrección y localizar por qué la mezcla es correcta precisamente cuando el resultado es indeseado.
- Demostrar que un estado que olvida no puede distinguir lo que nunca existió de lo que fue eliminado.
- Desmontar los tres intentos ingenuos de añadir borrado y ver qué falla en cada uno.
- Entender que borrar exige evidencia positiva del borrado, y qué implica eso para el almacenamiento.
Quitar rompe el dominio, no la mezcla
Retomemos el trazado con el que cerró la lección anterior, ahora con la aritmética de la monotonía delante. Una réplica que tenía un elemento y lo elimina produce un estado que ya no domina a su propio estado anterior: ha bajado en el orden parcial. La mezcla, sin embargo, sigue calculando el supremo, y el supremo de un estado que bajó con otro que no lo hizo es, necesariamente, un estado que contiene lo que el primero quiso soltar.
const A = { elementos: new Set(["x", "y"]) };
const B = { elementos: new Set(["x", "y"]) };
A.elementos.delete("x"); // decision local de A
const precede = (p, q) => [...p.elementos].every((e) => q.elementos.has(e));
precede({ elementos: new Set(["x", "y"]) }, A); // false: A retrocedio
const fusion = { elementos: new Set([...A.elementos, ...B.elementos]) };
fusion.elementos.has("x"); // true: x ha vuelto
Conviene resistir la tentación de leer esto como un defecto de la unión, porque el argumento no depende de ella. Toma cualquier función de mezcla que cumpla las dos exigencias que la hacían utilizable —que el resultado domine a ambos operandos y que no invente elementos— y aplícala a este par de estados. Como B contiene el elemento y el resultado debe dominar a B, el resultado contiene el elemento. No hay ninguna libertad de diseño que explotar aquí: la resurrección se sigue de las exigencias, no de la implementación elegida para satisfacerlas.
Hay además un detalle temporal que agrava el diagnóstico y que explica por qué este fallo sobrevive tanto tiempo en producción. La resurrección no ocurre cuando se borra, sino cuando una réplica rezagada vuelve a hablar, que puede ser minutos o semanas después y desde un dispositivo distinto del que provocó el problema. En el escritorio del desarrollador, con dos pestañas sincronizándose cada segundo, la ventana en la que el fallo es observable dura menos que un parpadeo. En el bolsillo de un usuario que estuvo tres días sin cobertura, dura tres días.
flowchart TD A1[replica A tiene x] --> A2[A quita x y baja en el orden] B1[replica B sigue teniendo x sin enterarse] --> M[mezcla igual al supremo] A2 --> M M --> R[x reaparece en las dos replicas] R --> C[la mezcla no fallo: hizo justo lo que promete] style A2 fill:#f9e2af,color:#11111b style R fill:#f38ba8,color:#11111b
La ausencia no se puede representar con ausencia
Hasta aquí el argumento es sobre el orden. Hay otro más profundo, y es el que cierra la puerta de verdad, porque no habla de la mezcla sino de la información disponible para calcularla. Una función de mezcla recibe dos estados y devuelve uno. No recibe historias, no recibe intenciones, no recibe el registro de lo que cada réplica hizo: recibe exactamente dos valores. De ahí se sigue algo que parece obvio al enunciarlo y que sin embargo destruye la mitad de los diseños posibles: si dos historias distintas producen el mismo valor, ninguna función de mezcla puede tratarlas de forma distinta.
// Dos historias opuestas, un unico valor: son literalmente el mismo dato
const nuncaTuvo = new Set([]); // en esta replica jamas se anadio x
const tuvoYQuito = new Set([]); // aqui se anadio x y despues se quito
const remota = new Set(["x"]); // una replica que sigue teniendo x
const r1 = new Set([...nuncaTuvo, ...remota]); // contiene x: correcto
const r2 = new Set([...tuvoYQuito, ...remota]); // contiene x: incorrecto
nuncaTuvo.size === tuvoYQuito.size; // true: ninguna mezcla puede separarlas
El resultado deseado difiere entre los dos casos y la entrada es idéntica, de modo que ninguna función determinista puede acertar en ambos. Como una mezcla no determinista es directamente inaceptable —dos réplicas obtendrían valores distintos partiendo de lo mismo—, la conclusión es que el estado tiene que cambiar. Borrar no puede consistir en dejar de tener algo, porque dejar de tener algo es indistinguible de no haberlo tenido nunca. Borrar tiene que consistir en tener algo nuevo: un dato que afirma que aquello fue retirado.
Esta es la inversión conceptual del nivel y conviene enunciarla con toda su fuerza, porque de ella se deducen las tres lecciones restantes. La eliminación no es la negación de una escritura, es una escritura más. La ausencia no es información; solo la afirmación de la ausencia lo es. Y como toda escritura en un sistema que solo crece, esa afirmación ocupa sitio, viaja por la red y hay que guardarla mientras exista alguien que pueda no haberla visto todavía.
Si vas a auditar un sistema propio en busca de este problema, no lo hagas con las herramientas de siempre. La resurrección no produce excepciones, no ensucia los registros, no aparece en las métricas de error y no se reproduce en un entorno de integración con réplicas bien conectadas. Su firma es un ticket de soporte que dice que algo volvió a salir y que el equipo cierra como no reproducible. La única forma fiable de encontrarla es construir el escenario a mano: dos réplicas, cortar la comunicación entre ellas, borrar en una, esperar, y solo entonces reconectar. Si tu suite de pruebas no puede expresar esa secuencia, no está probando sincronización, está probando dos clientes que casualmente se hablan rápido.
Los tres intentos que todo el mundo hace
Antes de aceptar la conclusión, casi todos los equipos recorren la misma secuencia de tres atajos, y vale la pena verlos fallar porque cada fallo aísla un requisito distinto.
Difundir el borrado como operación
Falla dos veces: exige una entrega fiable que un dispositivo apagado no puede dar, y aun teniéndola no dice nada sobre una adición concurrente que el borrador nunca vio.
Una marca de tiempo por elemento
Converge, pero decide por deriva de reloj en lugar de por causalidad, y deja en el estado una entrada por cada elemento borrado: una lápida sin nombre.
Una bandera de borrado en el dato
Es honesto y es el camino correcto, pero la mezcla de dos banderas necesita una regla, y esa regla ya es una política de producto disfrazada de detalle técnico.
Limpiar la marca al cabo de un tiempo
Reintroduce el antipatrón de la ventana del nivel 23: cualquier plazo elegido es demasiado corto para el portátil que vuelve de vacaciones.
El segundo merece código, porque es el que más lejos llega y el que mejor enseña la lección. La idea es guardar por elemento un registro con su estado y una marca, y quedarse con la marca mayor. Converge de verdad: la comparación es un orden total y la mezcla resultante es idempotente, conmutativa y asociativa.
// Intento 2: una marca por elemento. Converge, pero mira que decide y que deja
function mezclarPorMarca(a, b) {
const salida = new Map(a);
for (const [e, reg] of b) {
const previo = salida.get(e);
if (!previo || reg.ts > previo.ts) salida.set(e, reg);
}
return salida;
}
const A = new Map([["x", { vivo: false, ts: 1200 }]]); // borrado real en A
const B = new Map([["x", { vivo: true, ts: 1240 }]]); // A anadida antes, reloj adelantado
mezclarPorMarca(A, B).get("x").vivo; // true: gana la deriva del reloj
mezclarPorMarca(A, B).has("x"); // true: la entrada nunca desaparece
Las dos últimas líneas son las dos malas noticias, y la segunda es la interesante. La primera ya la conocemos desde el nivel 21: una marca física no establece causalidad, de modo que la decisión entre añadir y borrar la toma la deriva del hardware, y basta que el móvil vaya cuarenta milisegundos adelantado para que un borrado deliberado quede anulado por una adición anterior. La segunda es la que nadie anticipa: la clave del mapa sigue ahí, con su registro y su marca, ocupando sitio para siempre. Quien creía haber evitado las lápidas acaba de construir una, más cara que la de la lección tres y sin la propiedad que la haría útil.
Lo que borrar exige realmente
Recogiendo los tres hilos, el borrado impone tres requisitos que ninguna estructura puede esquivar, y el resto del nivel consiste en pagarlos de formas cada vez menos malas. El primero es evidencia positiva: el hecho de haber retirado algo debe estar representado por un dato que existe, no por un dato que falta. El segundo es monotonía preservada: esa evidencia debe añadirse, nunca sustituir ni destruir lo anterior, para que el estado siga subiendo en el orden y la mezcla siga siendo un supremo. El tercero es decidibilidad de la relación: la lectura debe poder responder si una retirada concreta afecta a una adición concreta, y eso obliga a que ambas sean identificables por separado.
// El requisito minimo: retirar es anadir un hecho que se relaciona con otro
const hechos = new Set();
const anadir = (e) => hechos.add({ tipo: "alta", elemento: e, id: crypto.randomUUID() });
const retirar = (id) => hechos.add({ tipo: "baja", altaAfectada: id });
function valor(hechos) {
const bajas = new Set([...hechos].filter((h) => h.tipo === "baja").map((h) => h.altaAfectada));
const vivos = [...hechos].filter((h) => h.tipo === "alta" && !bajas.has(h.id));
return new Set(vivos.map((h) => h.elemento));
}
Ese esbozo ya contiene, sin adornos, la forma de todo lo que viene. El conjunto de hechos solo crece, con lo que la convergencia queda intacta y no habrá que volver a discutirla. La retirada apunta a una adición concreta, con lo que dos adiciones distintas del mismo elemento son sucesos distintos y una baja puede afectar a una sin afectar a la otra. Y valor es la función de interpretación que la lección anterior anticipó: la que decide qué se ve, y por tanto la que carga con todas las decisiones discutibles. Las tres lecciones que siguen no cambian el almacén, solo cambian esa función y el precio que cobra.
Vale la pena medir el tamaño del cambio que acaba de ocurrir, porque atraviesa mucho más que la sincronización. En un sistema con un árbitro central, borrar consiste en emitir una orden a quien custodia la verdad, y la orden es efectiva porque no queda nadie con autoridad para contradecirla: el registro desaparece de la única copia que contaba y con él desaparece la pregunta de si existió. Al retirar ese árbitro —que es lo único que significa local-first— las copias dejan de ser reflejos de un original y pasan a ser afirmaciones autónomas sobre el mundo, y una afirmación no se cancela guardando silencio. Si tres personas sostienen que algo existe y tú simplemente dejas de sostenerlo, la suma de lo que se sabe no ha cambiado; para que cambie tienes que sostener activamente lo contrario, y para que tu contradicción sea evaluable tiene que decir a qué afirmación concreta responde. De ahí se sigue todo: los identificadores por adición de la lección cuarta, porque hay que poder señalar qué afirmación se niega; el crecimiento perpetuo de la lección quinta, porque una negación es un dato tan real como lo negado y hay que conservarla mientras exista un interlocutor que no la haya escuchado; y la imposibilidad, que ninguna librería resuelve, de un borrado verdaderamente inmediato y universal, porque no hay ningún acto local que pueda retirar del mundo una información que ya salió de aquí. Esto tiene consecuencias que exceden lo técnico y que conviene saber antes de prometer nada. Cuando un usuario pulsa eliminar espera que el dato deje de existir, y lo que tu sistema puede darle honestamente es que deje de mostrarse en toda réplica que haya recibido tu negación. Son cosas distintas, y la distancia entre ambas es exactamente el terreno donde viven la retención de datos, el derecho al olvido y el cifrado que hace ilegible lo que no se puede eliminar. La disciplina completa del nivel 44 —compactar, podar, olvidar de verdad— nace de que el borrado, en un sistema sin árbitro, es lo contrario de una desaparición: es una acumulación.
- Monta dos réplicas de un conjunto de solo crecimiento, córtales la comunicación, borra en una y reconecta. Anota cuánto tarda el elemento en volver.
- Escribe el par de historias indistinguibles con datos de tu dominio y comprueba que ninguna función de mezcla puede tratarlas distinto.
- Implementa el intento de la marca por elemento y busca el valor de deriva de reloj que invierte la decisión: sabrás cuántos milisegundos separan tu producto del fallo.
- Cuenta cuántas entradas quedarían en ese mapa tras un año de uso real de un usuario intenso y multiplícalo por tu base instalada.
- Localiza en tu producto una eliminación que se prometa como definitiva al usuario y escribe qué garantía puedes dar de verdad sin un árbitro central.