Añadir gana frente a borrar gana: las dos semánticas y sus consecuencias
Prioridad al alta o prioridad a la baja son las dos únicas salidas posibles del borrado concurrente, las dos convergen igual de bien, y la elección se ve en la pantalla del usuario mucho antes que en el código.
La lección anterior terminó en una disyuntiva que no admite tercera vía: cuando alguien elimina algo que otro está añadiendo o modificando a la vez, el sistema tiene que decidir si sobrevive el alta o sobrevive la baja. Conviene insistir en que no se trata de un detalle de implementación pendiente de resolver, sino de una elección de significado que ninguna propiedad matemática puede desempatar, porque ambas alternativas satisfacen exactamente las mismas propiedades. Las dos son conmutativas, asociativas e idempotentes; las dos producen convergencia fuerte; las dos se demuestran correctas con el mismo argumento de cuatro líneas. Lo único que las separa es qué le pasa al usuario cuando ocurre, y por eso esta es la lección del nivel que menos álgebra tiene y más consecuencias visibles produce. Elegir bien exige mirar el dominio, no el código, y exige además darse cuenta de que la elección no se hace una vez para la aplicación entera sino una vez por cada colección del modelo.
- Formular las dos semánticas con precisión y comprobar que ambas satisfacen las mismas propiedades.
- Identificar qué elige cada librería del ecosistema y con qué mecanismo lo implementa.
- Anticipar la manifestación concreta de cada semántica en la interfaz y en el soporte al usuario.
- Aplicar el criterio de asimetría de daño para asignar una semántica a cada colección del modelo.
Dos reglas simétricas, y las dos correctas
La regla de prioridad al alta es la que se obtiene de forma natural de la eliminación observada, y ya está implícita en la construcción de la lección anterior: el borrado solo alcanza a lo que quien borra tenía delante, de modo que cualquier alta concurrente sobrevive por no haber estado en ese contexto. Su enunciado informal es sencillo: si alguien añadió algo que el que borraba no llegó a ver, ese algo permanece.
La regla de prioridad a la baja es la simétrica exacta y se implementa con la misma maquinaria invertida. La baja registra el elemento eliminado y esa marca domina a cualquier alta que sea concurrente con ella, no solo a las que la precedieron causalmente. Su enunciado informal también es sencillo: si alguien eliminó algo, ese algo desaparece aunque otro lo estuviera añadiendo al mismo tiempo, y solo un alta que sea causalmente posterior al borrado —es decir, hecha por alguien que ya lo había visto— puede reponerlo.
Conviene separar esa regla de un pariente cercano con el que se confunde a menudo y que es bastante más severo. El conjunto de dos fases elimina de forma definitiva: un elemento borrado no puede volver nunca, ni siquiera mediante un alta causalmente posterior, porque la marca de eliminación no caduca ni admite contrapartida. Es una estructura más simple y a veces suficiente —sirve bien para identificadores consumidos o para invitaciones de un solo uso— pero no es lo mismo que dar prioridad a la baja. La prioridad a la baja solo domina lo concurrente; el conjunto de dos fases domina además todo el futuro, y esa diferencia decide si tu producto puede volver a añadir algo que alguien quitó.
// Prioridad al alta: la baja solo alcanza a las etiquetas observadas
function vivoAltaGana(elemento, altas, bajas) {
return [...altas[elemento] ?? []].some((et) => !bajas.has(et));
}
// Prioridad a la baja: la marca domina a todo lo concurrente con ella
function vivoBajaGana(elemento, altas, marcas) {
const marca = marcas[elemento];
if (!marca) return (altas[elemento] ?? []).length > 0;
return [...altas[elemento] ?? []].some((et) => sucedeDespues(et.vv, marca.vv));
}
Merece la pena comprobar que la segunda converge igual de bien, porque la intuición sugiere lo contrario y se equivoca. La condición de supervivencia es una propiedad de la relación causal entre dos operaciones, y esa relación no depende del orden en que lleguen los mensajes ni de cuántas veces se apliquen: si un alta sucede causalmente después de una baja lo sigue haciendo se mire cuando se mire. La función que decide la pertenencia es por tanto determinista sobre el conjunto de operaciones conocidas, y eso basta para que la unión sea conmutativa, asociativa e idempotente. Ninguna de las dos reglas es más segura que la otra en sentido matemático; solo son distintas.
El coste, en cambio, no es simétrico, y esa asimetría sí es un argumento técnico legítimo para elegir. La prioridad al alta necesita recordar qué etiquetas han sido eliminadas, es decir, información sobre el pasado, y esa información se puede resumir con un vector causal por réplica. La prioridad a la baja necesita además conservar el contexto de cada baja para poder compararlo con las altas que lleguen después, y ese contexto tiene que sobrevivir mientras siga siendo posible que llegue un alta antigua, cosa que en local-first es siempre. Dicho de otro modo, una implementación con prioridad a la baja no puede olvidar sus lápidas sin arriesgarse a resucitar precisamente lo que prometía eliminar, que es el fallo que la regla existía para impedir.
Conviene no confundir dos decisiones que a menudo se discuten juntas. La semántica de alta o baja resuelve la pregunta de si el elemento existe. El desempate resuelve la pregunta de qué valor tiene cuando existe y hay varios candidatos concurrentes. Una librería puede dar prioridad al alta y además desempatar el valor por identificador de réplica, o conservar todos los valores concurrentes, o aplicar último escritor. Son ejes independientes y mezclarlos al razonar produce discusiones que no llegan a ninguna parte.
Qué elige cada librería
El ecosistema se ha inclinado de forma bastante uniforme hacia la prioridad al alta, y no por casualidad: es la que se obtiene sin esfuerzo de la eliminación observada, es la que evita la pérdida silenciosa y es la que resulta más fácil de justificar en aplicaciones de contenido, que son la mayoría de las que se construyen sobre estas librerías. Conviene, aun así, saber que el mecanismo interno difiere y que el detalle importa a la hora de depurar.
Automerge
Modela cada clave conservando las escrituras concurrentes que nadie ha dominado causalmente, de modo que una escritura concurrente con un borrado sobrevive y la clave permanece.
Yjs
Guarda por clave una cadena de elementos identificados y la eliminación marca elementos concretos, así que un elemento nuevo escrito a la vez que el borrado no queda marcado y sobrevive.
Tipos de datos de Riak
Sus mapas y conjuntos se definieron desde el principio con eliminación observada, que es la formulación canónica de la prioridad al alta en un almacén distribuido.
Variantes con prioridad a la baja
Existen en la literatura y se usan donde la baja expresa una restricción de seguridad, como listas de permisos, revocaciones y bloqueos.
La diferencia práctica entre las dos primeras está en el desempate del valor, no en la semántica de presencia. Una conserva de forma explícita los valores concurrentes y expone al programador que hay más de uno, obligándole a decidir qué muestra; la otra aplica una regla determinista basada en el identificador de la réplica y devuelve un único valor, lo que produce un modelo más cómodo y esconde que hubo una elección. Ninguna de las dos posturas es superior en abstracto: la primera evita sorpresas al usuario a cambio de complicar cada punto de lectura, y la segunda simplifica el código a cambio de descartar en silencio.
Hay una advertencia de método que no sobra. Estas decisiones se documentan por versión y se han refinado con el tiempo en todos los proyectos citados, de modo que la afirmación útil no es la que memorizas sino la que compruebas. Escribe un caso de prueba que reproduzca el borrado concurrente en la versión concreta que usas, ejecútalo en tu integración continua y conviértelo en la fuente de verdad de tu equipo. Es media hora de trabajo y evita construir un producto sobre una suposición.
// La prueba que convierte una suposicion heredada en un hecho verificado
test("borrado concurrente en la version que usamos", () => {
const a = crearDocumento();
fijar(a, "tareas", ["pendiente"]);
const b = clonar(a); // las dos replicas parten del mismo estado
borrarClave(a, "tareas"); // A elimina sin haber visto lo de B
anadir(b, "tareas", "nueva"); // B escribe sin haber visto lo de A
const fusion = unir(a, b);
expect(leer(fusion, "tareas")).toMatchSnapshot(); // fija la semantica observada
});
Esa prueba vale bastante más que documentar el comportamiento actual. Al fijar el resultado en una instantánea, cualquier cambio de semántica introducido por una actualización de la dependencia aparece como un fallo de la integración continua en lugar de como un comportamiento raro que un usuario reportará seis meses después sin saber describirlo. Es la única forma práctica de apoyarse en una decisión de diseño ajena sin quedar a merced de ella.
Comprueba también qué ocurre un nivel más abajo, porque no siempre coincide. Un mapa puede dar prioridad al alta en sus claves y, sin embargo, resolver el valor de cada clave con último escritor, de modo que la clave sobrevive al borrado pero el contenido que sobrevive no es el que esperabas. La pregunta correcta no es qué semántica tiene la librería, sino qué semántica tiene cada clase de nodo de tu documento, y la respuesta hay que obtenerla nodo a nodo.
Cómo se manifiesta ante el usuario
Aquí es donde la elección deja de ser abstracta. La prioridad al alta produce un fenómeno con nombre propio y muy reconocible: el elemento que vuelve. Alguien limpia una lista compartida, sincroniza, y al rato aparecen dos de los elementos que había quitado, porque otra persona los estaba tocando desde el móvil sin conexión. La sensación que produce es de sistema que no obedece, y se agrava cuando la resurrección es parcial y lo que reaparece es un fragmento sin contexto: un elemento de una lista vacía, una nota con un solo campo, una carpeta con un archivo que nadie recuerda.
flowchart TD C[borrado concurrente con un alta] --> AG[prioridad al alta] C --> BG[prioridad a la baja] AG --> A1[el elemento reaparece tras sincronizar] AG --> A2[nunca se pierde trabajo] AG --> A3[el usuario repite el borrado y desconfia] BG --> B1[la lista queda limpia como se esperaba] BG --> B2[el trabajo concurrente desaparece sin aviso] BG --> B3[el usuario no sabe que perdio algo] style A3 fill:#f9e2af,color:#11111b style B2 fill:#f38ba8,color:#11111b
La prioridad a la baja produce el fenómeno opuesto y mucho más difícil de detectar: el trabajo que no llegó. Alguien añade tres elementos desde el tren, sincroniza al llegar, y no pasa nada visible porque otra persona había vaciado la lista mientras tanto. No hay error, no hay aviso y no hay rastro; desde el punto de vista de quien escribió, sus cambios simplemente no están, y la explicación más natural que se le ocurrirá es que la aplicación pierde datos. Esa hipótesis, una vez formada, es prácticamente imposible de desmontar.
La asimetría entre ambos fenómenos es la clave para elegir y conviene enunciarla sin adornos. La resurrección es visible, reparable y explicable: el usuario la ve, puede volver a borrar y se le puede contar por qué ocurrió. La pérdida silenciosa es invisible, irreparable e inexplicable: nadie la ve en el momento, el dato ya no está en ninguna parte y el soporte no tiene nada que investigar porque no hubo ningún fallo. En igualdad de condiciones, un error del primer tipo cuesta una molestia y uno del segundo cuesta la confianza en el producto.
Hay además una diferencia en cómo se detecta cada fenómeno que conviene tener presente al instrumentar. La resurrección es observable desde el propio sistema: se puede contar cuántas veces una clave que estaba eliminada vuelve a estar viva después de una fusión, y ese contador se implementa en una tarde. La pérdida silenciosa no es observable desde dentro por definición, porque el sistema hizo exactamente lo que su semántica prescribe y no tiene ninguna anomalía que registrar; la única señal disponible es indirecta y llega por soporte, en forma de personas que dicen que la aplicación pierde cosas y que no pueden aportar ningún detalle porque no vieron nada.
Sobre la frecuencia conviene evitar los dos extremos. El escenario exige concurrencia real sobre el mismo elemento, así que en la mayoría de los productos ocurre en una fracción pequeñísima de las sincronizaciones, y esa rareza es justamente la razón de que casi nadie lo pruebe. Pero la distribución no es uniforme: se concentra en los documentos con más colaboradores, que son los más visibles, y en las personas que trabajan horas seguidas sin conexión, que suelen ser las más comprometidas con el producto. Un fenómeno raro en promedio y habitual entre tus mejores usuarios no es un fenómeno raro.
Si eliges prioridad al alta, no dejes que el elemento reaparezca sin más. Márcalo como recuperado, agrúpalo en una zona aparte, muestra quién lo tocó y cuándo, y ofrece una acción de un solo gesto para volver a eliminarlo con el contexto ya actualizado. La diferencia entre un sistema que parece roto y uno que parece cuidadoso no está en el algoritmo, que es idéntico, sino en si el producto reconoce el fenómeno y lo explica en lugar de fingir que no ocurre.
Elegir por colección, no por aplicación
La regla que ordena la decisión no es una preferencia global sino una pregunta que se hace colección a colección: qué expresa la baja en este dato concreto. Si la baja expresa una limpieza, una preferencia estética o un cambio de opinión sobre contenido, entonces darle prioridad sobre el trabajo ajeno no está justificado y la elección correcta es el alta. Si la baja expresa una restricción —revocar un permiso, retirar un consentimiento, bloquear a alguien, sacar a un miembro de un espacio— entonces su incumplimiento tiene consecuencias que no son estéticas, y la elección correcta es la baja aunque cueste perder escrituras concurrentes.
Esa pregunta no siempre tiene respuesta obvia, y cuando no la tiene suele ser porque la colección está mezclando dos cosas distintas. Una lista de miembros de un espacio compartido es a la vez un directorio —contenido, con bajas que son preferencias— y una lista de acceso —restricción, con bajas que son revocaciones—, y modelarla como una sola colección obliga a elegir una semántica para las dos. La salida no es afinar el algoritmo sino separar el dato: la pertenencia visible por un lado, con prioridad al alta, y el permiso efectivo por otro, con prioridad a la baja o directamente fuera del documento. Cuando la pregunta se resiste, casi siempre es señal de que hay dos campos donde parecía haber uno.
La distinción se puede afinar con una pregunta operativa que casi siempre da la respuesta a la primera: si la baja falla y el elemento sobrevive, ¿el daño es una molestia o es una brecha? Un elemento de la lista de la compra que vuelve es una molestia. Un usuario cuyo acceso se revocó y que sigue pudiendo leer porque su credencial resucitó es una brecha. Esa asimetría es la misma que en seguridad separa las propiedades que deben cumplirse siempre de las que conviene que se cumplan pronto, y es la razón de que las listas de control de acceso no se modelen nunca con prioridad al alta.
Queda un caso intermedio que conviene nombrar porque aparece más de lo esperado: las colecciones donde la baja es una restricción pero perder escrituras concurrentes también es inaceptable. Suelen ser flujos con consecuencias externas —facturación, envíos, publicación— y la salida no es elegir mejor sino sacar la decisión del algoritmo. Ahí es donde entran la reserva del líder que vimos al hablar de pestañas, los tokens con caducidad corta que hacen que la revocación se aplique sola en poco tiempo, y la confirmación explícita antes de una operación irreversible. Un algoritmo de fusión no puede garantizar una invariante global, y pedirle que lo haga es el error de diseño que más caro sale en este terreno.
Queda por fijar el valor por defecto, porque siempre hay uno y conviene que sea deliberado en lugar de heredado. La recomendación defendible es prioridad al alta para todo salvo prueba en contrario, por la asimetría de daño ya descrita, con la condición de que la interfaz reconozca y explique la resurrección en vez de fingir que no ocurre. Un valor por defecto que optimiza el error visible y reparable se puede justificar ante cualquier usuario; uno que optimiza el error invisible e irreparable no se puede justificar ante ninguno.
Y conviene dejar constancia de dónde vive la decisión, porque su vida útil es más larga que la de casi cualquier otra parte del código. Si la semántica la impone la librería y no es configurable, escríbelo al lado de la definición del tipo junto con la consecuencia que asumes. Si es configurable, que la configuración esté en el código y no en un ajuste de despliegue, porque un parámetro que cambia el significado de los datos no puede vivir en un sitio donde alguien lo toque sin revisión. Los datos escritos bajo una semántica no se reinterpretan solos cuando se activa la otra: quedan como estaban, y a partir de ese momento el documento contiene dos épocas con reglas distintas.
La observación que ordena esta lección y que conviene llevarse por delante de cualquier decisión de esquema es que el tipo de dato borra la distinción que más importa. En el código, retirar una etiqueta de una nota y retirar a una persona de un espacio compartido son la misma llamada sobre la misma estructura, y esa uniformidad es justamente lo que hace que la elección de semántica se tome por defecto, una sola vez, para todo el modelo. Pero en el mundo son cosas de naturaleza distinta y no comparable. La primera es una preferencia: alguien decidió que sobraba, y si sobrevive un instante de más no ha pasado nada que no se arregle repitiendo el gesto. La segunda es una restricción: alguien declaró que a partir de ahora cierta cosa no debe ocurrir, y si sobrevive un instante de más lo que ha pasado es exactamente aquello que la baja pretendía impedir. La teoría de la concurrencia lleva décadas nombrando esa diferencia con otras palabras —hay propiedades cuyo incumplimiento se detecta en un instante concreto y no se puede compensar después, y hay propiedades que solo exigen que algo bueno acabe ocurriendo— y la traducción a este terreno es directa: la prioridad al alta es la elección correcta para todo lo que sea preferencia, porque optimiza que no se pierda trabajo y acepta a cambio un retraso visible y reparable; la prioridad a la baja es la elección correcta para todo lo que sea restricción, porque una restricción que se aplica tarde no se ha aplicado. Y el corolario que de verdad cambia cómo se escribe un esquema es que la pregunta no admite una respuesta única por aplicación: un mismo producto contiene las dos clases de baja simultáneamente, y tratarlas con una política uniforme garantiza equivocarse en la mitad de los casos, con la particularidad de que los errores en la mitad de las restricciones no se manifiestan como incomodidad sino como incidente. Por eso el trabajo real no consiste en escoger la semántica buena, que no existe, sino en recorrer el modelo campo por campo preguntando si esta baja es una opinión o una regla, y en dejar esa clasificación escrita al lado de la definición del tipo, donde la vea quien añada el siguiente campo dentro de dos años.
- Enumera todas las colecciones de tu modelo donde exista una operación de eliminación y anota junto a cada una si la baja expresa una preferencia o una restricción.
- Escribe una prueba automatizada que reproduzca el borrado concurrente en la librería y versión que usas, y comprueba empíricamente qué semántica obtienes.
- Para cada colección clasificada como restricción, verifica si tu librería te permite la prioridad a la baja y, si no, diseña el mecanismo externo que la sustituya.
- Implementa en la interfaz el tratamiento del elemento resucitado: marca, agrupa y ofrece la acción de volver a eliminar con el contexto actualizado.
- Añade un contador que registre cuántas veces al mes ocurre una resurrección real en producción, para decidir con datos cuánta interfaz merece el fenómeno.
- Documenta la clasificación al lado de la definición de cada tipo, con una línea que explique la consecuencia esperada si la baja se aplica tarde.