wandres.dev
CONCURRENCIA Y ESTADO · carreras y transiciones

Orden y consistencia: que el estado refleje la última intención

Las cuatro lecciones anteriores atacaron síntomas concretos de un mismo desajuste, y esta los reúne en un principio operativo: el estado final de una aplicación debe ser función de la última intención expresada por la persona, no de la última respuesta recibida de la red. Formular el problema así permite tratar de forma unificada el descarte de lecturas obsoletas, la pérdida de escrituras, la mezcla de transiciones y el desorden de mutaciones dependientes, y sobre todo permite elegir el mecanismo correcto según la relación entre las operaciones, que es de tres tipos y solo tres. Esta lección construye el vocabulario de época, cola por clave y clave de idempotencia, muestra por qué reintentar sin identidad de operación duplica efectos y termina con un protocolo mínimo de coherencia que cabe en una lista de comprobación aplicable a cualquier pantalla.

⏱ 20 min

Todo este nivel puede resumirse en una sola frase, y conviene enunciarla antes de justificarla porque es la herramienta que queda cuando se olvidan los detalles: el estado final debe reflejar la última intención del usuario, no la última respuesta del servidor. Cada uno de los defectos vistos hasta aquí es una violación distinta de esa regla. El buscador que muestra resultados viejos deja que la red decida qué intención era la vigente. La escritura que se pierde deja que la latencia decida qué edición contaba. La transición que mantiene datos obsoletos sin descartarlos deja que la interfaz mienta durante más tiempo. Y las mutaciones que llegan desordenadas dejan que el orden de la cola sustituya al orden de las decisiones. Reformulado así, el problema deja de ser una colección de trucos asíncronos y se convierte en un problema de correspondencia entre dos secuencias: la de lo que alguien quiso y la de lo que el sistema hizo. Esta lección cierra el nivel construyendo los mecanismos que mantienen alineadas esas dos secuencias y el criterio para elegir cuál corresponde a cada caso.

🎯 Al terminar esta lección sabrás
  • Enunciar la coherencia de una pantalla como una correspondencia entre intención vigente y estado visible.
  • Clasificar dos operaciones concurrentes como independientes, sustitutivas o dependientes, y derivar el mecanismo de cada caso.
  • Implementar épocas y colas por clave para preservar el orden allí donde el orden importa.
  • Usar claves de idempotencia para que los reintentos no dupliquen efectos ni reordenen resultados.

Tres relaciones posibles entre dos operaciones

Todo el diseño se simplifica al observar que dos operaciones concurrentes solo pueden estar en tres relaciones, y que cada una admite un mecanismo distinto que ya conocemos de las lecciones previas. Son independientes cuando afectan a partes disjuntas del estado, y entonces no hay nada que hacer: pueden ejecutarse y aplicarse en cualquier orden. Son sustitutivas cuando expresan intenciones sucesivas sobre la misma casilla, y entonces la correcta es la última emitida y todas las anteriores deben descartarse. Son dependientes cuando el resultado de una condiciona el significado de la otra, y entonces hay que serializarlas de verdad, porque descartar destruiría información y aplicar en desorden produciría un estado que nadie pidió.

🧩

Independientes

Cargar el perfil y cargar las notificaciones. Ninguna toca lo que toca la otra. No hace falta mecanismo alguno y añadirlo solo introduce acoplamiento y latencia.

🔀

Sustitutivas

Buscar por rea y después por react. La última expresada es la única válida. Mecanismo: token o época, con descarte silencioso de todo lo que no coincida.

⛓️

Dependientes

Crear una carpeta y mover un archivo dentro. Invertir el orden no da un resultado peor: da un error. Mecanismo: cola secuencial por clave de recurso.

💰

Dependientes con efecto externo

Cobrar y confirmar el pedido. Además del orden hay que garantizar unicidad, porque un reintento sin identidad de operación cobra dos veces. Mecanismo: cola más clave de idempotencia.

La clasificación tiene una propiedad que la hace utilizable en la práctica: es local y no exige entender el sistema entero. Para clasificar un par basta con mirar qué región del estado toca cada operación y si el resultado de una entra en el cálculo de la otra, dos preguntas que se responden leyendo dos funciones. No hace falta razonar sobre entrelazados posibles ni construir diagramas de todos los órdenes, que es exactamente el trabajo que hace inabordable el análisis de concurrencia en el caso general. La razón de que aquí se pueda ser tan económico es que las operaciones de una interfaz nacen de acciones humanas discretas y en cantidad muy pequeña por unidad de tiempo, de modo que casi todos los pares son independientes y los pocos que no lo son se identifican de un vistazo.

El error de diseño más común consiste en aplicar el mecanismo de las sustitutivas a un par de operaciones dependientes. Es fácil caer en él porque el patrón del token es cómodo y funciona en la mayoría de las pantallas: si el usuario renombra un elemento y acto seguido lo mueve, un token ingenuo descartaría el renombrado por considerarlo superado, cuando en realidad ambas intenciones eran válidas y sucesivas. La pregunta de control es directa: si aplico las dos, en su orden de emisión, obtengo lo que la persona quería. Si la respuesta es sí, son dependientes y hay que secuenciar. Si aplicar solo la última basta, son sustitutivas y hay que descartar.

Épocas y colas por clave

Vale la pena detenerse en qué es una unidad de coherencia, porque el término hace todo el trabajo y no es evidente. No es el componente, que es una unidad de composición visual y puede fragmentar en cinco piezas algo que debe estar de acuerdo consigo mismo. No es la aplicación, que agrupa cosas que no tienen por qué estarlo y cuya coordinación global solo produce bloqueos innecesarios. Es el conjunto máximo de casillas de estado tales que un observador humano notaría una incoherencia si dos de ellas correspondiesen a intenciones distintas. La caja de búsqueda y su lista de resultados forman una; el contador de notificaciones de la barra superior forma otra, independiente, y por eso puede actualizarse a su ritmo sin coordinarse con nada. Delimitar mal esta frontera es la causa de los dos errores simétricos del área: coordinar de más, que cuesta latencia y complejidad, y coordinar de menos, que cuesta corrección.

La época generaliza el token de la segunda lección. En lugar de un contador por componente, se mantiene un contador por unidad de coherencia, que suele ser una pantalla, un recurso o una clave de consulta. Cualquier cambio de intención sobre esa unidad incrementa la época, y toda escritura al estado debe presentar la época bajo la que se originó. La ventaja sobre la bandera local es que sobrevive a los remontajes, funciona con varios efectos concurrentes que escriben en la misma región y, al ser un número observable, permite diagnosticar en producción cuántas respuestas se están descartando, que es una métrica sorprendentemente informativa sobre la calidad de la red de tus usuarios.

// Epoca por unidad de coherencia, no por componente.
const epoca = useRef(0)

function cambiarIntencion(nueva: Filtro) {
  const mia = ++epoca.current
  cargar(nueva).then(r => {
    if (mia !== epoca.current) return     // intencion superada
    aplicar(r)
  })
}

// Cola por clave: preserva el orden solo donde el orden importa.
const colas = new Map<string, Promise<unknown>>()

function enCola<T>(clave: string, tarea: () => Promise<T>): Promise<T> {
  const previa = colas.get(clave) ?? Promise.resolve()
  const siguiente = previa.catch(() => {}).then(tarea)
  colas.set(clave, siguiente)
  return siguiente
}

Hay que subrayar un detalle de la implementación de la cola que decide si funciona bajo fallo. La cadena se construye sobre la promesa anterior con su error neutralizado, y esa neutralización es deliberada: si una tarea falla y no se absorbe su rechazo, toda la cola queda envenenada y las operaciones siguientes no llegan a ejecutarse nunca. Absorber el error en el encadenamiento no significa ignorarlo, porque quien llamó sigue recibiendo su propia promesa con el rechazo intacto; significa únicamente que el fallo de una operación no debe impedir que se intente la siguiente. La decisión de si una tarea posterior tiene sentido cuando la anterior falló es de dominio y pertenece a quien la encoló, no a la infraestructura de la cola.

La cola por clave merece un comentario sobre su granularidad, porque ahí se decide si el remedio es proporcionado. Una cola global serializa toda la aplicación y convierte cualquier operación lenta en un bloqueo general, lo que arruina el rendimiento sin mejorar la corrección de nada que no estuviera relacionado. Una cola por recurso, en cambio, deja correr en paralelo todo lo que es independiente y ordena solo lo que comparte destino, que es exactamente lo que se necesita. La clave adecuada suele ser el identificador del recurso afectado, y la señal de que la has elegido mal es que operaciones sobre entidades distintas se estén esperando entre sí.

flowchart TD
A[intencion del usuario] --> B[que relacion tiene con lo pendiente]
B -->|independiente| C[lanzar sin coordinacion]
B -->|sustitutiva| D[subir la epoca y descartar lo anterior]
B -->|dependiente| E[encolar por clave de recurso]
E --> F[tiene efecto externo]
F -->|si| G[anadir clave de idempotencia]
F -->|no| H[basta con el orden]
style C fill:#a6e3a1,color:#11111b
style G fill:#f9e2af,color:#11111b

Idempotencia: reintentar sin duplicar ni reordenar

Antes de llegar al reintento conviene fijar el vocabulario de las garantías de entrega, porque son tres y solo dos existen. Entregar como máximo una vez es lo que consigue un cliente que no reintenta: nunca duplica y a veces pierde. Entregar al menos una vez es lo que consigue un cliente que reintenta: nunca pierde y a veces duplica. Entregar exactamente una vez no se puede conseguir en el transporte, por mucho que la expresión aparezca en material comercial, porque exigiría saber si un mensaje llegó cuando precisamente lo que ha fallado es el canal por el que se sabría. Lo que sí se puede construir es un efecto que ocurre exactamente una vez, y se construye combinando entrega al menos una vez con una operación idempotente en el destino. Esa combinación es la única receta que sobrevive al contacto con una red real, y la clave de idempotencia es su implementación concreta.

Los reintentos son el mecanismo estándar frente a fallos transitorios y son también una fuente prolífica de desorden, porque una operación reintentada sale por segunda vez y puede adelantar a otra que salió después de la primera. Peor aún, un tiempo de espera agotado no significa que el servidor no la ejecutase: significa que no recibiste la confirmación. Reintentar a ciegas en esa situación produce dos cobros, dos pedidos o dos correos. La solución es dar identidad a la operación, no al intento: el cliente genera una clave estable en el momento de decidir la acción, la envía en todos los intentos y el servidor devuelve el resultado guardado si ya la vio.

// La clave nace con la intencion, no con el intento.
const clave = crypto.randomUUID()

async function confirmar(pedido: Pedido) {
  for (let intento = 0; intento < 3; intento++) {
    try {
      return await api.confirmar(pedido, { claveIdempotencia: clave })
    } catch (e) {
      if (!esTransitorio(e)) throw e
      await esperar(2 ** intento * 200)
    }
  }
  throw new Error("agotados los reintentos")
}
// El servidor guarda clave y respuesta. El segundo intento
// no vuelve a cobrar: devuelve la respuesta del primero.

Dos precisiones sobre esa clave evitan los fallos más frecuentes. La primera es que debe nacer con la intención y no con el intento, porque una clave generada dentro del bucle de reintentos es una clave distinta cada vez y por tanto no protege de nada; la firma correcta es la que recibe la clave como argumento desde fuera. La segunda es que el servidor debe guardar la respuesta y no solo la marca de que la vio, porque un cliente que reintenta necesita recibir el mismo resultado que habría recibido la primera vez, incluido el identificador del recurso creado. Un servidor que responde ya procesado sin devolver el resultado obliga al cliente a adivinar, y adivinar en ese punto es exactamente lo que se quería evitar.

ℹ️
Cada mecanismo del nivel deja una métrica que conviene publicar

Todos los remedios de estas cinco lecciones producen, casi gratis, un número que dice algo verdadero sobre el sistema. Las respuestas descartadas por época miden la dispersión real de latencias de tus usuarios. Los conflictos de versión miden la contención efectiva de cada recurso y revelan qué entidades están mal modeladas. La profundidad máxima de la cola por clave revela dónde se acumula trabajo dependiente. Y los aciertos de clave de idempotencia cuentan cuántos reintentos habrían duplicado un efecto si no existiera el mecanismo, que es la única forma de justificar su coste ante quien pregunte para qué sirve. Publicar esas cuatro series convierte la corrección de la concurrencia, que suele defenderse con argumentos teóricos, en algo con evidencia observable.

🧭

Delimita la unidad

Escribe qué casillas deben estar siempre de acuerdo. Sin esa frontera no se puede decidir nada, porque no se sabe qué se está protegiendo ni de qué.

🔢

Numera la intención

Una época por unidad, que sube con cada cambio de intención y que toda escritura al estado debe presentar. Es el árbitro y además una métrica.

⛓️

Ordena solo lo dependiente

Cola por clave de recurso, nunca global. Lo independiente corre en paralelo y lo sustitutivo se descarta: encolarlo todo es tan erróneo como no encolar nada.

🔐

Identifica lo irreversible

Toda operación con efecto fuera del proceso lleva clave estable desde su origen, y el servidor guarda la respuesta, no solo la marca de haberla visto.

Queda una consideración sobre el punto donde se coloca la barrera de coherencia, que es la decisión arquitectónica más importante de todo el nivel y la que más se descuida. Puede ponerse en el componente, y entonces cada pantalla resuelve sus carreras a mano y las resuelve mal la mitad de las veces. Puede ponerse en la capa de datos, y entonces una cache de consultas centraliza la deduplicación, el descarte y la invalidación para toda la aplicación, que es la razón principal por la que conviene usar una en lugar de escribir efectos sueltos. Y puede ponerse en el servidor, con transacciones, versiones y claves de idempotencia, que es el único sitio donde la garantía es real porque es el único que ve a todos los clientes. Las tres capas son complementarias y ninguna sustituye a las otras, pero el orden de fiabilidad es siempre el inverso al orden de comodidad.

Diseña la coherencia como un contrato de correspondencia, no como una colección de arreglos asíncronos

Lo que separa a un equipo que sufre estos defectos de uno que no los tiene no es el conocimiento de las herramientas, que están documentadas y son públicas, sino el momento en que se plantea la pregunta. Quien empieza escribiendo el efecto y añade el descarte cuando alguien reporta un fallo acumulará indefinidamente una colección de parches asíncronos que se contradicen entre sí, porque cada uno se diseñó para el caso que lo motivó y ninguno se diseñó para el conjunto. Quien empieza escribiendo el contrato descubre que las herramientas se deducen casi solas. Ese contrato cabe en cuatro frases y hay que escribirlo antes que el código. Primero, cuál es la unidad de coherencia de esta pantalla, es decir, qué conjunto de casillas de estado debe estar siempre de acuerdo entre sí, porque sin esa delimitación no se sabe qué se está protegiendo. Segundo, qué acciones cambian la intención vigente sobre esa unidad, que son las que deben subir la época y no otras. Tercero, qué relación tienen entre sí las operaciones que esas acciones lanzan, en los tres únicos tipos posibles, porque de ahí sale el mecanismo sin necesidad de deliberar. Y cuarto, qué operaciones tienen efectos observables fuera del proceso, porque solo esas necesitan identidad estable y solo esas pueden causar un daño que ningún arreglo en el cliente podrá revertir. Con esas cuatro respuestas escritas, elegir entre un token, una cola, una versión o una clave de idempotencia deja de ser una decisión de criterio y pasa a ser una consulta a una tabla. Sin ellas, cada carrera lógica se descubrirá en producción, se arreglará donde se manifestó en lugar de donde se origina, y volverá a aparecer con otra cara en la siguiente pantalla. La concurrencia no se domina acumulando remedios: se domina escribiendo con antelación qué significa que el estado sea correcto, porque solo entonces se puede saber si un remedio sobra.

⚔️ Escribe el contrato de coherencia de una pantalla real y verifícalo
  1. Elige una pantalla de tu aplicación y delimita por escrito su unidad de coherencia: qué casillas de estado deben estar siempre de acuerdo entre sí y cuáles pueden ir por libre.
  2. Enumera todas las acciones que cambian la intención vigente sobre esa unidad y marca cuáles deben incrementar la época. Justifica las exclusiones.
  3. Clasifica cada par de operaciones concurrentes posibles como independiente, sustitutiva o dependiente, y anota el mecanismo que le corresponde según la tabla de la lección.
  4. Implementa la cola por clave de recurso y demuestra con dos operaciones sobre entidades distintas que no se esperan entre sí. Después demuestra que dos sobre la misma entidad sí lo hacen.
  5. Añade clave de idempotencia a la única operación con efecto externo de esa pantalla y simula un tiempo de espera agotado seguido de reintento. Verifica en el servidor que el efecto ocurrió una sola vez.
  6. Instrumenta el contador de respuestas descartadas por época y publícalo como métrica. Observa su valor durante una semana y explica qué te dice sobre la red de tus usuarios.