wandres.dev
DETECTAR CONFLICTOS · y por qué LWW pierde datos

Detectar frente a resolver: dos problemas, dos herramientas

Detectar un conflicto es una pregunta mecánica con respuesta exacta y resolverlo es una decisión de política sin respuesta correcta, y casi todos los sistemas que fallan aquí han fundido ambas cosas en la misma línea de código.

⏱ 17 min

La lección anterior dejó una función que devuelve la palabra concurrentes. Lo que hagas a continuación con esa palabra es un problema completamente distinto del que resolviste para obtenerla, y la confusión entre ambos es, con diferencia, el error de diseño más caro de esta parte del track. Detectar es una pregunta cerrada, decidible y verificable con pruebas exhaustivas: dado el estado causal de dos escrituras, o son concurrentes o no lo son, y la respuesta no admite matices ni depende del dominio. Resolver es una pregunta abierta que no tiene respuesta correcta en ningún sentido matemático, que depende del significado de los datos, del usuario que los produjo y del producto que los muestra. Confundirlas produce sistemas que aplican una política sin haber formulado nunca la pregunta, y que por tanto son estructuralmente incapaces de contar lo que hicieron.

🎯 Al terminar esta lección sabrás
  • Distinguir la naturaleza lógica de ambos problemas: uno es decidible y el otro es una elección.
  • Enumerar las propiedades exigibles a un detector y por qué la exactitud es asimétrica.
  • Reconocer en el código el síntoma del colapso de capas y las cinco capacidades que ese colapso destruye.
  • Situar cada problema en su capa: la detección viaja con los metadatos, la resolución con el tipo de dato y el producto.

Dos preguntas con dos naturalezas distintas

Enunciadas una junto a otra, la asimetría salta a la vista. La primera pregunta es ¿estas dos escrituras son concurrentes sobre estado solapado? y su respuesta es una función pura de la historia causal registrada: mismos argumentos, misma respuesta, siempre, en cualquier réplica, sin conocer nada del dominio. La segunda es ¿qué estado debe quedar? y su respuesta depende de si el dato es un saldo bancario o un color de fondo, de si el usuario está delante o durmiendo, de si la aplicación es un editor colaborativo o un formulario médico. La primera pertenece a la teoría; la segunda, a la ingeniería de producto.

Esa diferencia de naturaleza tiene una consecuencia que conviene enunciar sin adornos: existe un detector correcto y existen muchas resoluciones defendibles. Puedes demostrar que tu detector es correcto, porque la corrección está definida —marca como concurrentes exactamente los pares incomparables en el orden causal—. No puedes demostrar que tu resolución es correcta, porque no hay ningún enunciado que decir; solo puedes argumentar que es adecuada a un propósito, y ese argumento pertenece al diseño y no a las matemáticas.

La asimetría se manifiesta también en cómo se comprueba cada una, y esa es la señal más práctica de que estamos ante dos problemas distintos. Un detector se prueba con propiedades: generas historias causales al azar, las aplicas en todos los órdenes y verificas que la conclusión no cambia; el criterio de éxito está definido y la máquina puede evaluarlo sin ayuda. Una política de resolución no admite ese trato, porque no hay ninguna propiedad que comprobar salvo el determinismo. Se evalúa contra una especificación escrita por personas, se discute, se cambia de opinión y se mide con incidencias de soporte. Cuando en un mismo módulo conviven algo que se demuestra y algo que se debate, lo que se demuestra deja de demostrarse.

De ahí se deduce también dónde vive cada una. La detección viaja con los metadatos de causalidad: vectores de versiones, identificadores de réplica, referencias a los estados que cada escritura vio. Es infraestructura genérica, escrita una vez, idéntica para todos los tipos de dato de tu aplicación e incluso reutilizable entre aplicaciones. La resolución vive pegada al tipo de dato y a la interfaz: cambia entre un contador y un texto, entre un campo obligatorio y uno decorativo, y evoluciona con el producto. Meterlas en el mismo módulo es acoplar lo que no cambia nunca con lo que cambia cada trimestre.

flowchart TD
E[llega una escritura remota] --> D[detector compara historias causales]
D --> N[no concurrente: aplicar sin ceremonia]
D --> C[concurrente sobre estado solapado]
C --> L[registrar el conflicto siempre]
L --> P[politica de resolucion segun el tipo de dato]
P --> A[fusion automatica]
P --> H[pedir decision al usuario]
style D fill:#89b4fa,color:#11111b
style C fill:#f38ba8,color:#11111b
style L fill:#f9e2af,color:#11111b
style P fill:#cba6f7,color:#11111b

Observa el nodo intermedio del diagrama, el que registra antes de decidir. Es el que desaparece en todos los sistemas que confunden las dos etapas, y su ausencia explica por qué esos sistemas no pueden responder nunca a la pregunta más elemental que un usuario les hará algún día: ¿qué le pasó a mi versión?

Las propiedades de un buen detector

Un detector no es simplemente una función que a veces dice que sí. Tiene cuatro propiedades exigibles, y ninguna de ellas es opcional si pretendes construir encima. Debe ser determinista, para que dos réplicas alcancen la misma conclusión sobre el mismo par sin necesidad de negociar. Debe ser local, para poder ejecutarse en cualquier nodo con la información que ese nodo ya tiene, sin consultar a nadie. Debe ser completo, en el sentido de no dejar pasar ningún par realmente concurrente. Y debe ser explícito: producir un objeto que nombre las partes en litigio, no un valor booleano que se descarta al instante siguiente.

La tercera propiedad merece un matiz importante, porque los dos errores posibles no cuestan lo mismo ni de lejos. Un falso positivo —marcar como conflicto algo que en realidad se podía fusionar sin discusión— genera ruido, quizá una pregunta innecesaria al usuario, quizá una entrada de más en un registro. Es un coste real y hay que administrarlo. Un falso negativo —no ver un conflicto que existía— genera pérdida silenciosa de datos, que es el único fallo de esta familia del que no hay retorno posible porque nadie se entera de que ocurrió. La asimetría es de varios órdenes de magnitud, y por eso un detector debe diseñarse conservador: ante la duda, marcar.

🎲

Determinista

La misma entrada produce la misma salida en cualquier réplica. Sin esto, dos nodos discrepan sobre si hubo conflicto y la divergencia se hace permanente.

📍

Local

Decide con los metadatos que el nodo ya posee. Un detector que necesita preguntar al servidor reintroduce la autoridad central que fuimos a quitar.

🕸️

Completo antes que preciso

Un falso positivo cuesta una pregunta de más. Un falso negativo cuesta el trabajo de alguien. La asimetría dicta el sesgo del diseño.

🏷️

Explícito

Devuelve las partes en litigio con sus identidades y su procedencia, no un booleano. Lo que no se nombra no se puede registrar ni mostrar.

Hay además una razón práctica para inclinarse hacia el detector conservador y es que su exceso de celo se puede corregir después sin tocar nada delicado. Si marcas de más, la política que consultes a continuación puede resolver la mayoría de esos casos automáticamente y el usuario no se entera; el falso positivo se queda en una entrada de registro. Si marcas de menos, no hay ninguna capa posterior que pueda arreglarlo, porque las capas posteriores solo ven lo que el detector les entrega. La corrección del exceso está disponible aguas abajo; la del defecto, en ningún sitio.

// Un detector explicito devuelve un caso, no un booleano que se evapora
function detectar(local, remoto) {
  const relacion = comparar(local.vv, remoto.vv);
  if (relacion !== "concurrentes") return null;      // no hay nada que registrar
  if (!solapan(local.alcance, remoto.alcance)) return null;

  return {
    tipo: "conflicto",
    alcance: interseccion(local.alcance, remoto.alcance),
    partes: [local, remoto],   // con identidad y procedencia, no solo valores
    detectadoEn: Date.now(),   // solo para el registro: jamas para decidir
  };
}

La última línea lleva un comentario que conviene tomarse en serio. Nada impide anotar la hora de pared en el registro de un conflicto, porque para investigar un incidente es útil saber cuándo pasó. Lo que está prohibido es que ese valor entre en la lógica de decisión, y la frontera entre ambos usos es tan fina que conviene marcarla en el propio código: un campo destinado a la observación no debe compartir nombre ni tipo con uno destinado al cálculo, o alguien acabará comparándolos.

La cuarta propiedad tiene además un beneficio que rara vez se anticipa: un detector explícito es comprobable de forma exhaustiva, porque es una función pura sobre estructuras pequeñas. Puedes generar historias causales al azar, aplicarlas en todos los órdenes posibles y verificar que la conclusión no varía. Ese tipo de prueba, basada en propiedades y no en ejemplos, es viable precisamente porque la detección no sabe nada del dominio. En cuanto le mezclas política, deja de serlo.

ℹ️
Detectar sin resolver ya es valioso por sí solo

Existe la idea equivocada de que un detector no sirve de nada mientras no exista la maquinaria completa de fusión, y esa idea retrasa indefinidamente la parte más barata del trabajo. Un sistema que solo detecta y registra ya es netamente mejor que uno que no detecta, aunque su política de resolución sea provisional: te da la tasa real de conflictos de tu aplicación en producción, te dice qué campos concentran el problema, convierte los informes de soporte en investigaciones con evidencia y te permite decidir con datos si la fusión sofisticada que estabas planeando merece el coste. Construir primero el detector y dejar la política para después no es una entrega a medias: es exactamente el orden correcto.

La resolución es una política, no un algoritmo

Pasa a la segunda pregunta y observa lo que ya no tienes. No hay ninguna propiedad matemática que distinga la respuesta buena de la mala, porque las dos escrituras concurrentes son igual de legítimas por construcción: ambas fueron hechas por usuarios reales, sobre estados válidos, sin información de la otra. Elegir una es descartar trabajo que nadie hizo mal. Lo único que la teoría te exige es que todas las réplicas elijan lo mismo, para que converjan; qué elijan, la teoría no lo dice y no puede decirlo.

Esa exigencia mínima, por cierto, no es trivial de cumplir y su incumplimiento produce el fallo más desconcertante de esta familia. Si tu política consulta algo que no está disponible en todas las réplicas por igual —la hora local, un valor de configuración del usuario, un número aleatorio, el orden en que llegaron los mensajes—, dos nodos pueden resolver el mismo conflicto de forma distinta y quedarse permanentemente en desacuerdo sobre un estado que ambos consideran ya resuelto. No hay error, no hay reintento, no hay síntoma: simplemente dos personas ven cosas diferentes para siempre. Una política de resolución debe ser una función pura de los valores en litigio y de su procedencia, y cualquier entrada adicional debe justificarse con mucho cuidado.

De esa exigencia mínima —convergencia, no corrección— nacen todas las estrategias que la siguiente lección examina, y también su enorme desigualdad de calidad. Quedarse con la escritura de mayor marca de tiempo converge. Quedarse con la de identificador de réplica más alto converge. Quedarse con la más corta converge. Las tres son igual de válidas desde el punto de vista de la convergencia y radicalmente distintas desde el punto de vista del usuario, y esa brecha entre lo que la teoría exige y lo que el usuario necesita es el espacio entero donde se juega el diseño de producto.

// La politica es un dato del tipo, no una rama del motor de sincronizacion
const politicas = {
  contador:   (a, b, base) => base + (a - base) + (b - base), // conmuta: no hay litigio
  etiquetas:  (a, b)       => new Set([...a, ...b]),          // union: fusion semantica
  titulo:     (a, b)       => ({ requiereDecision: [a, b] }), // devuelve el caso al humano
  colorFondo: (a, b)       => masReciente(a, b),              // trivial: nadie lo va a llorar
};

function resolver(campo, izquierda, derecha, base) {
  const politica = politicas[campo] ?? politicas.titulo; // por defecto, preguntar
  return politica(izquierda, derecha, base);
}

Fíjate en el valor por defecto de la última línea, porque condensa toda la postura de esta lección. Un campo sobre el que nadie ha pensado no recibe la fusión automática más cómoda: recibe la política más conservadora, que es preservar ambos valores y devolver la decisión a quien tiene el contexto. Invertir ese valor por defecto —fusionar salvo indicación contraria— es la decisión que convierte un olvido de diseño en una pérdida de datos, y es exactamente el valor por defecto que trae de fábrica la estrategia que estudiaremos a continuación.

El colapso de las dos capas

El síntoma es fácil de reconocer una vez sabes qué buscar. En los sistemas que confunden ambos problemas, no existe ninguna función que devuelva si hubo conflicto: existe una función que escribe, y dentro de ella una comparación que decide qué valor sobrevive. La detección no está ausente por descuido, está disuelta dentro de la resolución, y esa disolución es irreversible porque no hay ningún punto del flujo donde el hecho hubo dos versiones llegue a existir como valor.

// El colapso: la deteccion existe pero se consume en el acto y no deja rastro
function guardar(clave, valor, ts) {
  const actual = tabla.get(clave);
  if (!actual || ts > actual.ts) tabla.set(clave, { valor, ts }); // decidido y olvidado
}

Compáralo con la alternativa, que no es más lenta ni más compleja: una función que devuelve el veredicto, un registro que lo persiste y una política que se consulta después. Son tres piezas en lugar de una, y la diferencia de esfuerzo es de minutos. Lo que compran esos minutos son cinco capacidades que el colapso destruye de golpe y que nadie recupera después sin reescribir el núcleo: poder auditar cuántos conflictos ocurre de verdad, poder avisar al usuario de que su versión fue desplazada, poder conservar la versión perdedora para restaurarla, poder cambiar de política sin tocar el motor de sincronización, y poder probar la detección por separado con casos deterministas.

Hay un matiz que evita malinterpretar la recomendación: separar no significa duplicar esfuerzo ni añadir capas por gusto arquitectónico. El detector y la política siguen ejecutándose en la misma llamada y en el mismo milisegundo; lo que cambia es que entre ambos existe un valor intermedio que se puede inspeccionar, registrar, contar y enrutar. Toda la diferencia entre las dos posturas cabe en si el resultado de la comparación causal llega a tener nombre o se consume dentro de una condición. Un nombre cuesta cero en tiempo de ejecución y lo compra todo.

⚠️
Si tu motor de sincronización sabe qué es un título, algo se ha mezclado

Hay una prueba de olor que detecta el colapso en cinco minutos sobre cualquier base de código. Busca el módulo que integra los cambios remotos y mira si en él aparecen nombres de campos de tu dominio, tipos de tu modelo o reglas de negocio. Si aparecen, la política se ha filtrado a la infraestructura, y a partir de ese momento cada nuevo campo con una regla distinta añade una rama a un archivo que debería ser genérico y estable. El motor debe saber comparar historias causales y nada más; qué hacer cuando dos son incomparables es una pregunta que debe delegar hacia arriba, a un mapa de políticas que el equipo de producto pueda leer y modificar sin abrir el núcleo.

Separar mecanismo de política es la idea más rentable que la informática ha producido dos veces

Lo que esta lección propone tiene un nombre que no nació aquí y que conviene reconocer, porque saber que uno está aplicando un principio general —y no una preferencia estilística— cambia la firmeza con la que se defiende en una revisión de código. La separación entre mecanismo y política se formuló explícitamente en el diseño del sistema operativo Hydra a mediados de los años setenta, se convirtió en el criterio arquitectónico de los micronúcleos y reaparece desde entonces en cada capa del oficio: el planificador que ofrece prioridades sin decidir cuáles importan, el gestor de memoria que proporciona el desalojo sin dictar la estrategia de reemplazo, el sistema de tipos que impone comprobación sin fijar el estilo, el motor de renderizado que expone eventos sin prescribir la interacción. La forma del principio es siempre idéntica: aísla la parte que es demostrablemente correcta y no cambia nunca de la parte que es opinable y cambia constantemente, y hazlo con una frontera de datos —una tabla, un mapa, un valor devuelto— y no con una llamada que las funda. El motivo por el que este principio rinde tanto en concreto en la sincronización local-first es que aquí la asimetría entre ambas partes es de las más extremas que existen en el oficio. El mecanismo es un orden parcial, un objeto matemático de una estabilidad total: la comparación de historias causales que escribas hoy será literalmente idéntica dentro de una década, porque describe una propiedad de la información y no una decisión de nadie. La política, en cambio, es la parte más volátil de tu producto: cambiará cuando cambie el tipo de dato, cuando descubras que a los usuarios les importa un campo que creías decorativo, cuando el equipo de diseño rehaga la pantalla de revisión, cuando el departamento legal exija conservar las versiones descartadas durante siete años. Fundir un objeto matemático inmutable con una decisión de producto trimestral en la misma línea de código no es un atajo pragmático, es una apuesta a que ninguna de las dos evolucionará, y esa apuesta se pierde siempre. Y hay un último dividendo, que es el que de verdad justifica la disciplina: cuando el hecho hubo un conflicto existe como valor de primera clase en tu sistema, todas las decisiones que vienen después —avisar o callar, fusionar o preguntar, conservar o descartar— se vuelven decisiones que puedes tomar, revisar y revertir. Cuando ese hecho nunca llega a existir, no es que hayas elegido mal: es que has renunciado por adelantado a poder elegir, y ninguna cantidad de trabajo posterior recupera una información que no se registró.

⚔️ Parte tu integración en dos
  1. Localiza en tu código la función que integra un cambio remoto y comprueba si en algún punto llega a existir un valor que signifique hubo conflicto, o si la decisión se toma y se olvida en la misma expresión.
  2. Extrae la comparación causal a una función pura sin dependencias del dominio y escríbele pruebas exhaustivas con historias generadas al azar.
  3. Añade un registro persistente de conflictos detectados, con ambas versiones y su procedencia, aunque tu política siga siendo provisional.
  4. Sustituye las ramas por tipo de dato del motor por un mapa de políticas externo, y pon como valor por defecto la que preserva ambas versiones.
  5. Despliega solo el detector durante dos semanas y mide la tasa real de conflictos de tu aplicación antes de invertir un euro en fusión sofisticada.