wandres.dev
CONTADORES · el CRDT más simple

El límite: no puedes impedir que baje de cero sin coordinación

Ninguna fusión local puede garantizar que el total no cruce el cero porque la restricción habla del conjunto y cada réplica solo ve su parte, y por eso el stock disponible no se modela con un contador.

⏱ 18 min

Llegado este punto, el contador parece resuelto: incrementa, decrementa, converge, no pierde nada y cuesta poco. La demanda que aparece a continuación en cualquier proyecto real es tan modesta que nadie sospecha que sea imposible: que el contador no baje de cero. Quedan tres unidades en el almacén, no vendas cuatro. Quedan doscientos euros de presupuesto, no gastes trescientos. La restricción se escribe en una línea, se comprueba en un instante y cualquier base de datos con un servidor la garantiza sin despeinarse. Sin autoridad central no se puede garantizar, y no por falta de ingenio en la fusión ni por inmadurez de las implementaciones actuales: es un límite demostrable, y su demostración cabe en el mismo ejemplo de dos réplicas que abría el nivel. Esta lección construye la contradicción, la eleva a enunciado general sobre qué invariantes sobreviven a una fusión, y recorre con implementaciones reales las tres cosas que sí se hacen en su lugar, que no son sustitutos peores sino modelos distintos de lo que estaba ocurriendo.

🎯 Al terminar esta lección sabrás
  • Reproducir la violación del límite inferior con dos réplicas que respetan la regla cada una por su cuenta.
  • Enunciar qué clase de invariantes preserva una fusión y por qué una desigualdad sobre el total no está en esa clase.
  • Implementar el reparto por cuotas, que convierte una restricción global en restricciones locales verificables sin red.
  • Elegir con criterio entre repartir, compensar y coordinar según el coste real de equivocarse.

La contradicción: dos réplicas y un solo ejemplar

El escenario no necesita adornos. El contador vale uno: queda una unidad. Dos réplicas están desconectadas y ambas ven ese uno. Cada una comprueba la regla antes de actuar, cada una la encuentra satisfecha, y cada una decrementa. Ninguna ha hecho nada incorrecto según toda la información de que disponía. Cuando se fusionan, el estado resultante es impecable —los dos mapas se han combinado sin descartar nada, la convergencia es perfecta— y la lectura devuelve menos uno.

// Cada replica comprueba la regla y la respeta. El total no.
function decrementarSiHay(c, replica, cantidad = 1) {
  if (valorPN(c) - cantidad < 0) return { ok: false, estado: c };
  return { ok: true, estado: decrementarPN(c, replica, cantidad) };
}

const base = incrementarPN(crearPN(), "almacen", 1); // queda una unidad

const enAna = decrementarSiHay(base, "ana"); // ok: true, Ana ve 0
const enBen = decrementarSiHay(base, "ben"); // ok: true, Ben ve 0

valorPN(fusionarPN(enAna.estado, enBen.estado)); // -1

Es importante localizar dónde falla, porque el instinto es culpar a la comprobación. La comprobación es correcta: en el momento en que se ejecuta, sobre el estado que la réplica conoce, la respuesta es verdadera. Lo que falla es que la propiedad que se está comprobando no es una propiedad del estado local, sino del estado global, y ese estado global no existe en ningún sitio hasta después de la fusión. La réplica no está verificando la regla: está verificando una aproximación de la regla sobre una fotografía parcial, y la aproximación se queda obsoleta en cuanto otro escribe.

flowchart TD
E[estado inicial con una unidad disponible] --> A[Ana comprueba y ve una unidad]
E --> B[Ben comprueba y ve una unidad]
A --> DA[Ana decrementa y su copia vale cero]
B --> DB[Ben decrementa y su copia vale cero]
DA --> F[fusion sin perdida de informacion]
DB --> F
F --> R[el total vale menos uno]
R --> C[cada regla local se cumplio y la global no]
style DA fill:#89b4fa,color:#11111b
style DB fill:#89b4fa,color:#11111b
style R fill:#f38ba8,color:#11111b

No es un fallo del contador, es un límite demostrable

El enunciado general se deduce sin dificultad de lo que ya sabemos. Una fusión correcta calcula la cota superior mínima de dos estados. Una restricción sobre el dato es un predicado que separa los estados válidos de los inválidos. La pregunta de si la restricción sobrevive a la replicación es, por tanto, la pregunta de si el conjunto de estados válidos está cerrado bajo la fusión: si dos estados válidos se combinan, ¿es válido el resultado? Cuando la respuesta es sí, la invariante se mantiene sola y no hace falta ninguna coordinación. Cuando la respuesta es no, ninguna implementación puede mantenerla sin que alguien hable con alguien antes de actuar.

Aplicado al contador, la comprobación es inmediata y no deja resquicio. La invariante la suma de los incrementos menos la suma de los decrementos es mayor o igual que cero no está cerrada bajo la fusión, porque la fusión hace crecer el mapa de decrementos y por tanto puede hacer bajar el valor por debajo de cualquier cota que se hubiera respetado antes. Esta es exactamente la propiedad que la literatura de sistemas distribuidos llama confluencia de invariantes, y este contador es su contraejemplo canónico: es el caso que se usa para demostrar que existen restricciones perfectamente ordinarias que ninguna cantidad de disponibilidad puede sostener a la vez.

📝
La familia de invariantes que sí sobrevive es reconocible a simple vista

Merece la pena tener el criterio a mano porque se aplica en minutos a un modelo entero. Sobreviven a la fusión las invariantes que solo pueden hacerse más ciertas cuando el estado crece: que un contador no baje de un valor que solo sube, que un conjunto contenga un elemento que alguien añadió, que una fecha sea posterior a un mínimo, que un campo esté marcado una vez que alguien lo marcó. No sobreviven las que pueden dejar de cumplirse al añadir información: unicidad de una clave, cardinalidad máxima de un conjunto, límite inferior de un contador que baja, cualquier suma acotada por arriba y cualquier relación de exclusión entre dos elementos. La regla mnemotécnica es que las invariantes con la forma al menos son gratis y las que tienen la forma como mucho se pagan.

Qué se hace en su lugar

Reconocido el límite, la ingeniería consiste en elegir dónde pagarlo. Hay tres respuestas reales, y la mejor de las tres es casi siempre la que menos se parece a un problema técnico.

La primera es repartir el recurso por adelantado. En lugar de una restricción sobre el total, cada réplica recibe una cuota propia y solo puede gastar de ella; la conjunción de las restricciones locales implica la global, y cada réplica puede verificar la suya sin hablar con nadie. Es la técnica que la literatura llama reserva o escrow, y es la única de las tres que conserva íntegra la autonomía en el camino crítico.

// Reserva: el total se reparte una vez y despues solo se mueve entre replicas
function crearReserva(inicial) {
  return { inicial, gastado: {}, cedido: {} }; // gastado y cedido solo crecen
}

function cuota(c, r) {
  const salidas = Object.values(c.cedido[r] ?? {}).reduce((s, n) => s + n, 0);
  const entradas = Object.values(c.cedido).reduce((s, d) => s + (d[r] ?? 0), 0);
  return (c.inicial[r] ?? 0) - salidas + entradas;
}

function disponible(c, r) {
  return cuota(c, r) - (c.gastado[r] ?? 0);
}

function gastar(c, r, n) {
  if (n > disponible(c, r)) return { ok: false, estado: c }; // rechazo local, sin red
  return { ok: true, estado: { ...c, gastado: incrementar(c.gastado, r, n) } };
}

function ceder(c, de, a, n) {
  if (n > disponible(c, de)) return { ok: false, estado: c };
  const destinos = incrementar(c.cedido[de] ?? {}, a, n);
  return { ok: true, estado: { ...c, cedido: { ...c.cedido, [de]: destinos } } };
}

Lo que hace correcta esta construcción no es evidente y conviene señalarlo, porque es el argumento que se reutiliza siempre que se reparte algo. Una réplica calcula su cuota a partir de tres cosas: su dotación inicial, lo que ha cedido —que solo ella escribe— y lo que ha recibido, que escriben otras. La información que le falta es únicamente de la tercera clase, y esa información solo puede aumentar su cuota. Por tanto su cálculo local es siempre una estimación por defecto, su comprobación es conservadora, y el estado que produce sigue respetando la invariante después de fusionarse con cualquier otro. Se ha convertido una desigualdad global e inverificable en una familia de desigualdades locales con un único escritor cada una, que es la definición operativa de haber eliminado la coordinación del camino crítico.

El reparto tiene un coste propio que hay que anticipar y que en la práctica decide si la técnica sirve: rechaza de más. Una réplica puede quedarse sin cuota mientras sobra en tres réplicas dormidas, y el usuario recibe un no cuando el recurso existía. La mitigación no es repartir mejor al principio, porque la demanda futura no se conoce, sino reequilibrar de forma continua: mantener una parte del total sin asignar bajo una réplica coordinadora, ceder desde ella cuando alguien avisa de que va corto, y devolver la cuota ociosa al cabo de un tiempo sin uso. Con esa disciplina, la coordinación deja de estar en el camino crítico de cada operación y pasa a ocurrir en segundo plano, con una frecuencia proporcional a lo desequilibrada que esté la demanda y no al número de acciones del usuario, que es exactamente el cambio que se buscaba.

La segunda respuesta es aceptar la violación y compensarla, y no es una rendición sino lo que hace la mayoría de los negocios que funcionan. Un comercio que vende una unidad de más no ha sufrido una corrupción de datos: ha adquirido una obligación, que resuelve con un aviso, un reembolso, un pedido pendiente o una disculpa. El coste de esa compensación es medible en dinero y comparable con el coste de la alternativa, que es rechazar ventas para no arriesgarse nunca. Cuando la compensación es barata y la sobreventa es rara, coordinar es la decisión cara.

La tercera es coordinar de verdad, y sigue siendo legítima. Cuando el recurso es indivisible, la compensación es inaceptable y el reparto no tiene sentido —un asiento concreto, un nombre de usuario, una transferencia de dinero—, la respuesta correcta es que ese dato viva bajo una autoridad y que la operación exija conexión. Lo importante es que esa decisión se tome para ese dato y no para la aplicación entera: el resto del modelo puede seguir siendo local-first sin ninguna contradicción.

🧾

Repartir por cuotas

Convierte una restricción global en restricciones locales de un solo escritor. Mantiene la autonomía y cuesta rechazar antes de tiempo cuando el reparto queda desequilibrado.

🩹

Aceptar y compensar

La respuesta de la mayoría de los negocios reales. Cuesta dinero por incidente y no cuesta latencia por operación, que suele ser el mejor cambio disponible.

🔒

Coordinar el dato concreto

Correcta para recursos indivisibles. El error no es usarla, es extenderla a todo el modelo por no haber separado qué exigía exclusividad.

🎟️

Cambiar el modelo

Casi siempre lo mejor: dejar de contar unidades y empezar a registrar reservas identificadas, que es lo que de verdad estaba ocurriendo.

El contador es el recuento, no la puerta

La conclusión práctica del nivel se puede formular como una separación de papeles que evita la mayoría de los errores. Un contador convergente es excelente para saber cuánto hay: vistas, votos, mensajes sin leer, unidades vendidas, kilómetros recorridos, tareas completadas. Todos esos datos comparten una característica: nadie sufre ningún daño si el número es provisional durante unos minutos, y el valor final es correcto sin importar en qué orden llegaron las contribuciones. Un contador convergente es pésimo para autorizar una acción, porque autorizar exige comparar contra un umbral y ese umbral habla del total.

Conviene además desconfiar del enunciado del problema cuando aparece la palabra existencias. Un almacén con tres unidades no tiene el número tres: tiene tres objetos distinguibles, y el modelo que cuenta en lugar de identificar ya ha perdido información antes de que empiecen los problemas de replicación. En cuanto se modelan reservas con identidad propia, muchas de las preguntas difíciles se vuelven fáciles —quién reservó qué, en qué orden, qué se libera si alguien cancela— y la única que queda difícil, que dos reservas apunten al mismo objeto, es exactamente la que siempre requirió coordinación y ahora está aislada en un punto pequeño y visible del sistema.

La coordinación no se elimina: se acota, se abarata y se saca del camino crítico

El error de lectura más caro de todo este material consiste en interpretar los tipos de datos convergentes como una promesa de que la coordinación es un problema resuelto, y esta lección existe precisamente para desactivarlo antes de que produzca una arquitectura imposible de rescatar. El resultado que hay que llevarse no es que a veces haga falta coordinar: es que existe una frontera exacta y calculable entre lo que puede sostenerse sin coordinación y lo que no, que esa frontera no depende de la habilidad del equipo ni de la madurez de las librerías, y que se determina con una sola pregunta mecánica formulada sobre cada invariante del modelo: si tomo dos estados que la cumplen y los fusiono, ¿sigue cumpliéndose? Todo lo que responde que sí es gratis para siempre y escala sin límite. Todo lo que responde que no cuesta una ida y vuelta, o cuesta un reparto, o cuesta una disculpa; no hay una cuarta opción y no la habrá, porque no se trata de una limitación de la técnica actual sino de una consecuencia de que la información tarda en viajar. Lo que sí está enteramente en tus manos, y es donde se juega la calidad del diseño, son las tres transformaciones que esta lección ha ido mostrando sin nombrarlas. La primera es acotar: la coordinación se aplica a una invariante concreta y no a un modelo entero, de modo que el trabajo de diseño consiste en localizar con precisión qué campo exige exclusividad, para que el noventa y cinco por ciento restante del modelo no pague por él. Casi todas las aplicaciones que se declaran incompatibles con local-first lo son por un solo dato al que nadie se molestó en aislar. La segunda es abaratar mediante fortalecimiento, que es lo que hace el reparto por cuotas y que merece entenderse como una técnica general: se sustituye una invariante global no cerrada bajo la fusión por una conjunción de invariantes locales más fuertes, cada una verificable por un único escritor con la información que ya tiene, y cuya conjunción implica la original. La invariante fortalecida rechaza más casos que la original —una réplica puede quedarse sin cuota mientras sobra en otra— y a cambio no necesita hablar con nadie; esa asimetría, aceptar rechazos de más para no pedir permiso nunca, es el patrón que aparece una y otra vez en los sistemas distribuidos que consiguen ser rápidos sin ser incorrectos. La tercera es desplazar en el tiempo: la coordinación que no puede eliminarse casi nunca tiene que ocurrir en el instante de la acción del usuario, y puede hacerse por adelantado, cuando la aplicación abre y reparte cuotas, o por detrás, cuando la sincronización detecta una violación y dispara una compensación. Un sistema que reparte al arrancar y compensa al fusionar coordina exactamente lo mismo que uno que pregunta al servidor en cada clic, y produce una experiencia completamente distinta. La consecuencia final es un cambio de pregunta que conviene adoptar de manera consciente. Deja de preguntar si tu aplicación puede ser local-first, porque esa pregunta admite un contraejemplo trivial en cualquier dominio y no lleva a ninguna parte. Pregunta qué fracción de tu modelo está cerrada bajo la fusión, qué te costaría cada una de las invariantes que no lo están, y si esas invariantes se pueden fortalecer, aplazar o compensar. Esa lista, campo por campo, es el verdadero documento de arquitectura de un sistema local-first, y casi nadie la escribe.

⚔️ Encuentra tus invariantes cerradas y pon precio a las demás
  1. Reproduce la violación del límite inferior con dos réplicas y comprueba que ambas comprobaciones locales devolvieron verdadero.
  2. Recorre tu modelo y clasifica cada invariante según si se conserva al fusionar dos estados válidos, sin excepciones ni casos que resolver luego.
  3. Implementa el reparto por cuotas y verifica que ninguna secuencia de gastos concurrentes puede superar la dotación inicial total.
  4. Añade la cesión entre réplicas y mide cuántos rechazos de más produce el reparto frente a un sistema coordinado.
  5. Calcula, para tu invariante más cara, el coste anual de compensar los incidentes y compáralo con el coste de exigir conexión en cada operación.
  6. Reescribe uno de tus contadores de existencias como un registro de reservas identificadas y comprueba qué preguntas del negocio pasan a tener respuesta.