wandres.dev
CONTADORES · el CRDT más simple

PN-Counter: restar rompe la monotonía y la solución es contar dos veces

Decrementar destruye la única propiedad que sostenía al contador creciente, y la salida no es cambiar la fusión sino descomponer el contador en dos que solo crecen y restarlos en el momento de leer.

⏱ 18 min

El contador creciente funcionaba por una razón que ahora conviene recordar con precisión: cada casilla del mapa solo subía, de modo que el máximo entre dos versiones de la misma casilla siempre elegía la más informada. Basta con permitir que una casilla baje para que ese razonamiento se desmorone entero, y no de forma sutil: una resta seguida de una fusión con cualquier copia anterior queda deshecha, y queda deshecha una y otra vez cada vez que aparece un rezagado, sin error, sin aviso y sin manera de detectarlo desde dentro del sistema. La reparación es de una economía notable y establece el patrón que se repetirá durante el resto del track: en lugar de buscar una fusión más lista, se descompone la operación conflictiva en dos operaciones que sí son monótonas —incrementos por un lado, decrementos por otro— y se reconstruye el valor observable restándolas en el momento de leer. Esta lección construye esa estructura, demuestra que sigue siendo correcta, y examina lo que se paga y lo que, pese a las apariencias, no se recupera.

🎯 Al terminar esta lección sabrás
  • Reproducir el fallo exacto que produce permitir que una casilla del mapa disminuya.
  • Implementar un contador con incremento y decremento como par de contadores crecientes.
  • Entender que la monotonía se ha mudado del valor observable al estado interno, y qué implica eso al comparar réplicas.
  • Reconocer las variantes que abaratan la estructura y las trampas que reintroducen la pérdida.

El decremento deshace la única regla que funcionaba

La tentación es evidente y hay que agotarla antes de descartarla: si incrementar era sumar a la casilla propia, decrementar debería ser restar de la casilla propia, dejando el resto de la estructura intacto. El fallo aparece en la primera fusión con una copia rezagada, que en local-first no es un caso raro sino el caso normal.

// Lo que NO funciona: dejar que una casilla baje y seguir fusionando por maximo
let ana = { ana: 5 };            // Ana ha aportado cinco
ana = { ana: ana.ana - 3 };      // Ana resta tres en su propia casilla: queda 2

const rezagado = { ana: 5 };     // una copia anterior que seguia circulando
fusionar(ana, rezagado);         // { ana: 5 }: la resta se ha deshecho sola

// Y no es un problema del maximo: con la suma seria { ana: 7 }, aun peor

Conviene ver por qué el daño es peor de lo que sugiere el ejemplo. La copia rezagada no tiene que venir de otra réplica: puede ser una copia de seguridad restaurada, un mensaje reentregado por el transporte o el propio estado que el dispositivo guardó en disco antes de la resta. Y como el máximo es idempotente, la restauración no ocurre una vez y se corrige: ocurre cada vez que ese estado antiguo vuelve a aparecer, de modo que la resta se deshace indefinidamente y el usuario ve un número que se niega a bajar. Es el mismo síntoma que producía un reloj que retrocede en el nivel veintitrés, y por el mismo motivo de fondo: se ha construido una operación que baja en un orden donde la fusión asume que todo sube.

La lectura general del fallo merece enunciarse porque se aplicará a docenas de casos: la corrección de una fusión por cota superior mínima no depende de la fusión, depende de que todas las operaciones locales sean monótonas respecto del orden. Si una sola operación baja, la estructura deja de converger hacia el estado correcto y pasa a converger hacia el máximo histórico, que es una cosa distinta y casi siempre indeseable.

Dos contadores que solo crecen y una resta al leer

La descomposición es directa. Se guardan dos mapas: uno para los incrementos, llamado tradicionalmente P por positivo, y otro para los decrementos, llamado N por negativo. Cada uno de los dos es un contador creciente completo, con sus mismas reglas y su misma invariante de propiedad de casilla. Decrementar el contador no consiste en restar en ninguna parte: consiste en incrementar el mapa de decrementos. Y el valor observable es la diferencia entre las dos sumas, calculada al leer.

// PN-Counter: dos contadores crecientes y una resta en la lectura
function crearPN() {
  return { p: {}, n: {} };
}

function incrementarPN(c, replica, cantidad = 1) {
  return { p: incrementar(c.p, replica, cantidad), n: c.n };
}

function decrementarPN(c, replica, cantidad = 1) {
  return { p: c.p, n: incrementar(c.n, replica, cantidad) };
}

function valorPN(c) {
  return valor(c.p) - valor(c.n);
}

function fusionarPN(a, b) {
  return { p: fusionar(a.p, b.p), n: fusionar(a.n, b.n) };
}

La corrección se hereda sin necesidad de volver a demostrar nada, y ese es justamente el interés de la construcción. El producto de dos retículos es un retículo, y su cota superior mínima es la de cada componente por separado; como fusionarPN aplica la fusión conocida a cada mitad, es conmutativa, asociativa e idempotente por herencia directa. Las operaciones locales suben en el orden del producto, porque cada una toca una sola mitad y solo hacia arriba. No hay nada nuevo que probar: la novedad no está en el álgebra, está en la consulta.

// Comprobacion: dos replicas restan sin conexion y ninguna resta se pierde
let x = decrementarPN(incrementarPN(crearPN(), "ana", 10), "ana", 3); // ana ve 7
let y = decrementarPN(incrementarPN(crearPN(), "ben", 4), "ben", 1);  // ben ve 3

const unido = fusionarPN(x, y);
valorPN(unido);                                    // 10
valorPN(fusionarPN(fusionarPN(unido, x), y));      // 10, reentregas incluidas
flowchart TD
OP[operacion no monotona restar] --> D[descomponer en dos monotonas]
D --> P[mapa P de incrementos que solo crece]
D --> N[mapa N de decrementos que solo crece]
P --> F[fusion por maximo en cada mapa]
N --> F
F --> Q[consulta suma de P menos suma de N]
Q --> V[valor observable que si puede bajar]
style P fill:#a6e3a1,color:#11111b
style N fill:#a6e3a1,color:#11111b
style V fill:#f9e2af,color:#11111b

La monotonía se muda del valor al estado

Aquí está el desplazamiento conceptual que da valor a la lección y que conviene no pasar por encima. El valor observable de la estructura sí baja: es lo que se pedía. Lo que nunca baja es el estado interno, porque las dos mitades solo crecen. Hemos separado dos cosas que en el contador creciente estaban pegadas y que la intuición confunde con facilidad: la monotonía es un requisito del estado replicado, no una propiedad del dato que el usuario ve. Mientras el estado suba, la fusión es correcta; lo que la consulta haga después con ese estado —restar, filtrar, ordenar, negar— es asunto suyo y no afecta a la convergencia.

De ahí sale una consecuencia práctica que sorprende la primera vez y que produce errores reales: no se pueden comparar dos réplicas por su valor. Dos estados con el mismo valor observable pueden ser causalmente incomparables, y un estado con valor menor puede ser estrictamente más informado que otro con valor mayor. La pregunta quién va por delante solo tiene sentido en el retículo, es decir, comparando los dos mapas casilla a casilla, exactamente como hacía la función comparar de la lección anterior.

// El valor no ordena: hay que comparar los mapas, no los numeros
function compararPN(a, b) {
  const p = comparar(a.p, b.p);
  const n = comparar(a.n, b.n);
  if (p === n) return p;
  if (p === "iguales") return n;
  if (n === "iguales") return p;
  return "concurrentes";
}
⚠️
El decremento sigue siendo propiedad exclusiva de la casilla propia

Una réplica solo puede incrementar la casilla que le pertenece en N, igual que en P. Parece obvio y se viola constantemente por una razón concreta: cuando una réplica quiere anular la aportación de otra, la manera intuitiva es restarla donde está. Si Ana intenta compensar los cinco de Ben escribiendo en n["ben"], dos réplicas comparten casilla y el máximo vuelve a descartar operaciones. La forma correcta es que Ana registre su propio decremento en n["ana"], aunque conceptualmente esté deshaciendo trabajo ajeno: el estado no tiene por qué reflejar quién causó el cambio, solo quién lo escribió, y confundir ambas cosas es el error más común al implementar esta estructura.

Lo que se paga y lo que no se recupera

El coste inmediato es que el estado se duplica: dos mapas en lugar de uno, con sus dos juegos de identificadores de réplica. En la práctica el factor real es menor que dos, porque solo aparecen entradas para las réplicas que efectivamente escribieron en cada mitad, y en muchos modelos hay muchas más réplicas que incrementan que réplicas que decrementan. Aun así, el orden de magnitud es el mismo y hereda el problema que estudia la última lección del nivel: crece con el número de escritores.

Hay un coste menos visible y más importante, y es la pérdida de asociación entre incrementos y decrementos. La estructura sabe cuánto se sumó y cuánto se restó en total por réplica, pero no sabe qué decremento cancelaba qué incremento. Eso significa que no se puede consultar el histórico, no se puede deshacer una operación concreta y no se puede auditar quién revirtió qué. Si el dominio necesita esas preguntas, el contador no es la estructura adecuada y hay que subir a un conjunto de operaciones identificadas, que cuesta memoria proporcional al número de operaciones en lugar de al número de réplicas.

✂️

Descomposición monótona

El patrón general: toda operación que no sube se parte en componentes que sí suben, y la consulta las recombina al leer.

📉

El valor baja, el estado no

La monotonía es un requisito del estado replicado. Lo que la consulta haga después es indiferente para la convergencia.

🚫

El valor no ordena réplicas

Dos estados con el mismo número pueden ser concurrentes. Comparar siempre en el retículo, nunca por la lectura.

🕳️

Sin trazabilidad

No queda registro de qué decremento anuló qué incremento. Si el dominio lo necesita, esta estructura es demasiado pequeña.

Existe una variante que reduce el estado a un solo mapa guardando un par de números por réplica en lugar de dos mapas separados, y es la que suele aparecer en las implementaciones de producción porque ahorra un juego de claves y una recorrida. No cambia nada del razonamiento: es la misma estructura con otra disposición en memoria, el par se fusiona tomando el máximo de cada componente por separado y sigue siendo válido exactamente por las mismas razones. Lo que no es una variante válida es guardar un único entero con signo por réplica y fusionar por máximo del valor absoluto o por cualquier otro criterio ingenioso: en cuanto una casilla puede bajar, el fallo de la primera sección vuelve entero.

Restar no se resolvió: se rodeó, y ese rodeo es el método completo del campo

Conviene ser exacto sobre lo que ha ocurrido en esta lección, porque la versión superficial —para restar se usan dos contadores— es una receta y lo que hay debajo es un método. No hemos encontrado una fusión capaz de manejar decrementos: hemos demostrado, de hecho, que no la hay, porque cualquier fusión sobre un estado donde las escrituras pueden bajar puede ser deshecha por un rezagado. Lo que hemos hecho es cambiar el estado replicado por otro del que el que queríamos es una función, eligiendo el nuevo de modo que todas las operaciones sobre él suban. Formulado así, el procedimiento se puede aplicar a ciegas y es literalmente el que genera el catálogo entero de estructuras convergentes: dada una operación que no es monótona, búscale una descomposición en componentes monótonas y una consulta que recomponga la semántica original. Los conjuntos que admiten borrado son este mismo par —un conjunto de altas y otro de bajas, presente lo que está en el primero y no en el segundo— y las lápidas que arrastran son exactamente el mapa N con otro nombre. Los conjuntos con etiquetas únicas son el mismo par con la granularidad bajada al elemento individual para que un alta posterior pueda distinguirse de la que se borró. Los registros multivalor son el par degenerado donde la consulta devuelve la anticadena en lugar de una diferencia. Y las secuencias de texto, que ocuparán varios niveles más adelante, son la misma idea empujada hasta su límite: un conjunto de inserciones identificadas que solo crece, un conjunto de borrados que solo crece, y una consulta —el recorrido en orden— que hace todo el trabajo semántico. En cuanto se ve el patrón, el catálogo deja de ser una lista que memorizar y pasa a ser una consecuencia. Hay dos precios estructurales que este método cobra siempre y que conviene reconocer de antemano en lugar de descubrirlos en producción. El primero es que el estado crece de forma irreversible: como nada baja jamás, la información de las operaciones deshechas permanece para siempre, y en el contador eso es barato porque un decremento solo aumenta un número, pero en un conjunto significa lápidas y en un texto significa que cada carácter borrado sigue ocupando sitio. Toda la investigación sobre compactación y recolección de basura en este campo consiste en intentar tirar parte de esa historia, y es difícil por un motivo que ya se puede anticipar: borrar una entrada del estado es una operación que baja, es decir, exactamente la clase de operación que la construcción prohíbe. El segundo precio es que la consulta puede producir valores que ninguna réplica escribió y que el dominio no admite. La diferencia entre dos sumas es un entero cualquiera, con signo, sin cota inferior y sin relación con ninguna secuencia de operaciones que alguien haya visto; el estado es impecable y el número que sale puede ser un disparate desde el punto de vista del negocio. Ese segundo precio no es un detalle de implementación ni algo que se arregle con más cuidado: es una barrera, y es el tema íntegro de la lección siguiente, donde se demuestra que ninguna cantidad de ingenio en la fusión permite garantizar que el resultado de la consulta respete una restricción como no bajar de cero, y donde se ve qué hacen en su lugar los sistemas que sí necesitan esa garantía.

⚔️ Descompón la resta y comprueba lo que se pierde
  1. Implementa la variante rota que resta en la casilla propia y provoca la restauración con una copia de seguridad antigua.
  2. Construye el contador de dos mapas y verifica las tres propiedades sobre el producto, no sobre cada mitad por separado.
  3. Escribe compararPN y encuentra dos estados con el mismo valor observable que sean causalmente concurrentes.
  4. Reimplementa la estructura como un solo mapa de pares y comprueba que produce los mismos resultados con menos claves.
  5. Haz que una réplica intente compensar el incremento de otra escribiendo en su casilla, y mide cuántas operaciones desaparecen.
  6. Intenta responder con este estado a la pregunta quién revirtió el incremento de ayer y anota exactamente qué información te falta.