Idempotencia y eventos duplicados
En un sistema distribuido el mismo evento llega dos veces con total normalidad: por reintento de red, por reproceso de una cola, por doble pulsación o por una bandeja de salida que confirmó tarde. Una transición que no tolera la repetición produce cobros duplicados, contadores inflados y correos enviados dos veces. Esta lección demuestra que el grafo es idempotente por topología pero el contexto y los efectos no lo son, y desarrolla las tres capas de defensa: deduplicación por identidad del evento, acumuladores diseñados para converger y claves de idempotencia en la frontera con el exterior.
Hay una frase que separa a quien ha operado un sistema distribuido de quien todavía no: el evento llegará dos veces. No es una posibilidad remota que convenga contemplar por prudencia, sino un suceso cotidiano que ocurre cada día en cualquier sistema con colas, reintentos o clientes con conexión inestable. El cliente pulsa dos veces porque la respuesta tardó; la biblioteca de red reintenta porque el tiempo de espera venció mientras el servidor ya procesaba; el consumidor de la cola recibe de nuevo el mensaje porque se reequilibró el grupo antes de confirmar; la bandeja de salida reintenta porque no supo si el efecto anterior llegó a ejecutarse. Ninguno de esos actores está funcionando mal. La entrega exactamente una vez no existe como garantía de transporte, y todo diseño que la presuponga está construyendo sobre una promesa que nadie firmó.
- Enumerar las causas reales por las que un mismo evento alcanza dos veces al proceso.
- Distinguir la idempotencia que el grafo da gratis de la que hay que construir en el contexto.
- Implementar deduplicación por identidad del evento con garantía transaccional.
- Diseñar la frontera con servicios externos mediante claves de idempotencia estables.
El grafo es idempotente, el contexto no
La primera buena noticia es estructural. Si un evento provoca la transición de pendiente a pagado y el estado pagado no declara ninguna respuesta a ese mismo evento, la segunda entrega no hace nada: la máquina la descarta por no tener transición aplicable en la posición actual. Esa protección viene de regalo con el modelo y es una de las razones profundas por las que un statechart es mejor sustrato para el backend que un conjunto de funciones sueltas, donde el segundo marcarPagado volvería a escribir la columna y a encolar el envío.
Conviene medir hasta dónde llega ese regalo, porque confiar en él más de la cuenta es la forma elegante de equivocarse. La protección topológica cubre exactamente un caso: que el estado destino no declare respuesta al evento que lo produjo. Deja de cubrir en cuanto el evento vuelve a ser legal en la posición actual —un PAGO_CONFIRMADO que también se acepta en pagado porque alguien lo añadió para cubrir pagos parciales—, en cuanto la transición es reflexiva y reejecuta sus acciones de entrada, y en cuanto el proceso ha avanzado a un estado desde el que ese evento vuelve a tener sentido tras un ciclo. Ninguno de esos tres casos es exótico, y los tres son invisibles en el diagrama si no se buscan a propósito.
La mala noticia es igual de estructural. En cuanto la transición modifica el contexto de forma acumulativa, o en cuanto el estado destino admite el mismo evento que lo produjo, la protección desaparece.
// Frágil: la segunda entrega del mismo cobro infla el total.
on: {
COBRO_PARCIAL: {
actions: assign({ cobrado: ({ context, event }) => context.cobrado + event.importe }),
},
}
// Robusto: el importe se deriva del conjunto de cobros vistos, sin acumular.
on: {
COBRO_PARCIAL: {
guard: ({ context, event }) => !context.cobrosVistos.includes(event.cobroId),
actions: assign({
cobrosVistos: ({ context, event }) => [...context.cobrosVistos, event.cobroId],
cobrado: ({ context, event }) => context.cobrado + event.importe,
}),
},
}
La diferencia entre ambos bloques es la que separa un contador de un conjunto. El contador solo conoce su valor y no puede saber si ya sumó ese importe; el conjunto conoce las identidades que ha visto y por tanto puede reconocer una repetición. Esta es la traducción práctica de una idea conocida en sistemas distribuidos: las estructuras que convergen bajo repetición son las que registran hechos identificables, no las que agregan números anónimos.
Tolerar la repetición del mismo evento y tolerar que dos eventos distintos lleguen en orden invertido son propiedades diferentes, y confundirlas produce diseños a medias. Un proceso puede ser perfectamente idempotente y romperse igualmente si el evento de entrega llega antes que el de envío. La idempotencia se resuelve con identidad; el desorden se resuelve con guardas sobre la posición del grafo, que rechazan lo que no corresponde a la fase actual, o con un estado explícito de espera para lo que llegó demasiado pronto.
Deduplicar por identidad, no por parecido
Contador ciego
Suma un importe sin saber cuál. Toda repetición lo corrompe y ninguna lectura posterior permite detectarlo. Es el patrón que hay que erradicar primero.
Conjunto de identidades
Registra qué hechos ha visto. Repetir uno no cambia nada porque ya pertenece al conjunto, y el agregado se deriva de él cuando hace falta.
Asignación absoluta
Escribir el estado que el evento afirma, en lugar de incrementar, converge por naturaleza: aplicar dos veces el mismo hecho deja el mismo valor.
Guarda de posición
Rechazar el evento que no corresponde a la fase actual protege del desorden, que es un problema distinto de la repetición y necesita su propia defensa.
La defensa general no vive dentro de la máquina sino delante de ella. Todo evento que entra al sistema debe traer un identificador estable generado por quien lo origina, y la aplicación del evento debe registrar ese identificador en la misma transacción que escribe el snapshot. Si el registro falla por conflicto de unicidad, el evento ya se aplicó y no se vuelve a aplicar.
export async function aplicar(procesoId: string, evento: EventoConId) {
return db.$transaction(async (tx) => {
const yaVisto = await tx.eventoAplicado.findUnique({
where: { procesoId_eventoId: { procesoId, eventoId: evento.id } },
})
if (yaVisto) return { repetido: true }
const fila = await tx.proceso.findUniqueOrThrow({ where: { id: procesoId } })
const actor = createActor(pedido, { snapshot: pedido.resolveState(fila.snapshot) })
actor.start()
actor.send(evento)
const siguiente = actor.getPersistedSnapshot()
actor.stop()
await tx.eventoAplicado.create({ data: { procesoId, eventoId: evento.id } })
await tx.proceso.update({ where: { id: procesoId, revision: fila.revision }, data: { snapshot: siguiente, revision: fila.revision + 1 } })
return { repetido: false }
})
}
El detalle que decide la corrección es que la marca de evento aplicado y el snapshot nuevo se escriben juntos o no se escribe ninguno. Una implementación que registre el identificador antes de procesar dejará eventos marcados como aplicados que nunca se aplicaron si algo falla en medio; una que lo registre después dejará ventanas en las que la repetición sí pasa. La transacción cierra ambas grietas de una vez, y por eso la deduplicación pertenece a la misma capa que la persistencia y no a una utilidad aparte.
| Origen del duplicado | Identificador estable adecuado | Ventana de retención |
|---|---|---|
| Reintento del cliente HTTP | clave enviada por el cliente en la cabecera | horas |
| Doble envío de formulario | testigo emitido al abrir el formulario | vida de la sesión |
| Reproceso de mensaje en cola | identificador del mensaje del transporte | días |
| Reintento de la bandeja de salida | identificador de la fila de la bandeja | hasta la confirmación |
| Reenvío de notificación externa | identificador del evento del proveedor | según su política de reintentos |
El error más común es generar el identificador dentro de la función que ejecuta el intento. Si cada reintento crea uno nuevo, la deduplicación no protege de nada: el sistema ve eventos distintos porque literalmente lo son. La clave debe crearse una sola vez, en el punto donde nace la intención del usuario o del sistema, y viajar sin cambios por todos los reintentos posteriores, incluidos los que ocurren tras un reinicio del servicio.
La frontera con el mundo exterior
Conviene además decidir qué se responde ante un duplicado detectado, porque la elección tiene efectos aguas arriba. Devolver un error deja al cliente creyendo que su operación falló cuando en realidad se completó, y suele desencadenar un tercer intento; devolver el mismo resultado que la primera vez es lo correcto y exige guardar la respuesta junto a la marca de aplicación, no solo el hecho de haberla aplicado. Esa fila con la respuesta congelada es lo que convierte la deduplicación en verdadera idempotencia observable: el emisor recibe siempre lo mismo, ignora cuántas veces preguntó y deja de reintentar.
La deduplicación interna protege el estado, pero no impide que un efecto ya enviado se vuelva a enviar cuando la respuesta se perdió y la bandeja reintentó. Ese tramo requiere una defensa propia, y la única que funciona consiste en pedirle al receptor que reconozca la repetición, para lo cual hay que darle una identidad que él pueda comparar.
sequenceDiagram participant Bandeja participant Pasarela participant Maquina Bandeja->>Pasarela: cobrar con clave de idempotencia Pasarela-->>Bandeja: sin respuesta por caida de red Bandeja->>Pasarela: reintento con la misma clave Pasarela-->>Bandeja: mismo resultado, sin cobro nuevo Bandeja->>Maquina: evento COBRO_OK con id estable Maquina->>Maquina: si el id ya se vio, se descarta
Queda un caso incómodo que conviene nombrar porque se descubre siempre en producción: la deduplicación no puede retener identidades para siempre, y la ventana de retención define el punto exacto en que el sistema deja de estar protegido. Un mensaje reentregado tres semanas después de que se purgara su identidad se aplicará como si fuera nuevo. La defensa no es guardar todo eternamente sino elegir la ventana a partir de la política de reintentos real de cada origen, dejando un margen generoso, y apoyarse en la topología del grafo para los casos que la excedan: un evento de pago que llega cuando el proceso ya está en un estado terminal no encuentra transición y se descarta sin necesidad de recordar nada.
Cuando el servicio externo no ofrece claves de idempotencia, la estrategia cambia de naturaleza: en lugar de evitar el duplicado hay que detectarlo consultando. El proceso entra en un estado de verificación que pregunta al proveedor si la operación existe antes de volver a intentarla, y solo reintenta cuando la respuesta es negativa. Ese estado adicional parece un coste, pero es exactamente la clase de honestidad que el modelo aporta: hace visible en el grafo que existe una incertidumbre real sobre el mundo, en vez de esconderla dentro de una función que decide a ciegas.
Consultar un catálogo, recalcular un precio o reenviar un correo transaccional que el destinatario tolera repetido son efectos donde el reintento ciego es aceptable. Cobrar, emitir una factura, reservar inventario escaso o enviar una notificación regulada no lo son. La distinción no depende del verbo sino de si repetir el efecto crea una obligación nueva. Anotar esa clasificación junto a cada actor invocado ahorra la mitad de las discusiones de diseño de un sistema transaccional.
Cuesta aceptar que los duplicados no son un defecto que haya que erradicar, sino el precio necesario de la resiliencia, y la resistencia a aceptarlo produce arquitecturas frágiles. La cadena de razonamiento es breve y no tiene escapatoria: como la red puede perder la respuesta pero no la petición, quien reintenta no puede distinguir entre no llegó y llegó pero no me enteré. Ante esa ambigüedad solo hay dos políticas posibles. No reintentar produce pérdidas silenciosas —operaciones que el usuario cree hechas y que nunca ocurrieron—, y reintentar produce duplicados. La industria eligió hace décadas la segunda, y no por comodidad sino porque un duplicado es un problema detectable y reparable mientras que una pérdida silenciosa es invisible hasta que alguien cuadra las cuentas. Todo lo demás se sigue de esa elección: si el transporte garantiza al menos una vez, la responsabilidad de aparentar exactamente una vez se traslada al receptor, y el único mecanismo con el que un receptor puede reconocer que ya vio algo es la identidad. De ahí que la idempotencia no sea una técnica sino una consecuencia lógica, y que su implementación se reduzca siempre a lo mismo: dar nombre a los sucesos y recordar los nombres vistos. Una máquina de estados encaja en este mundo mejor que casi cualquier otra abstracción porque ya razona en términos de posición y legalidad: sabe negarse a lo que no corresponde ahora, que es la mitad del trabajo. La otra mitad, recordar identidades, es lo que este nivel le añade. Cuando ambas están en su sitio, el sistema deja de intentar que el mundo sea ordenado y empieza a funcionar correctamente en el mundo desordenado que de verdad existe.
- Recorre tus eventos y anota cuáles pueden llegar dos veces y por qué motivo concreto de los cinco de la tabla.
- Busca en tu contexto todos los acumuladores numéricos y convierte al menos uno en un registro de identidades vistas.
- Añade la tabla de eventos aplicados con restricción de unicidad y muévela dentro de la misma transacción que el snapshot.
- Reproduce a mano una entrega doble y verifica que el segundo intento devuelve repetido y no altera el estado.
- Clasifica cada efecto externo entre reintentable y creador de obligación, y asigna clave de idempotencia a los segundos.
- Modela un estado de verificación para el efecto cuyo proveedor no admita claves y comprueba que solo reintenta al confirmar la ausencia.
- Fija la ventana de retención de identidades por origen y justifica cada plazo con la política de reintentos real de ese transporte.