La petición peligrosa no es la primera. Es la misma petición que llega otra vez, porque quien envía no oyó la primera respuesta, o la oyó y reintentó igual. Los proveedores de pago hacen esto. Las colas hacen esto. Un navegador hace esto cuando una persona pulsa el botón dos veces. Si el sistema trata la segunda copia como trabajo nuevo, cobras dos veces, envías dos veces o escribes dos líneas de libro por una sola apuesta.
Da por hecho el duplicado
Diseño el manejador para que la segunda entrega sea un éxito que no hace nada. La primera hace el trabajo y guarda una clave: el id de evento del proveedor, o una clave que envió el cliente, única en la base de datos. La segunda encuentra la clave y devuelve el mismo resultado. Sin segundo cargo. Sin segundo correo. Sin segunda fila.
La restricción de unicidad es la parte en la que confío. Una comprobación en el código de la aplicación, un instante antes del insert, pierde la carrera cuando dos workers despiertan a la vez. La base de datos no pierde esa carrera. El que pierde recibe un conflicto y lo convierte en “ya está hecho”.
| Primera llegada | Segunda llegada | |
|---|---|---|
| Consulta | La clave es nueva | La clave ya está guardada |
| Escribir | Una fila, un efecto lateral | Ninguna fila nueva |
| Responder | Aceptado | El mismo ya aceptado |
| Si el proceso muere a mitad | La bandeja de salida aún debe el mensaje | No debe un segundo |
El mensaje sale con la escritura, o no sale
Enviar el correo, o el mensaje de la cola, dentro de la petición y antes del commit es la forma de obtener un mensaje para una transacción que se revirtió. Escribo el mensaje saliente en la misma transacción que la fila de negocio, y un worker aparte lo publica. Si el proceso muere, la fila y el mensaje están los dos o no está ninguno. El worker puede reintentar la publicación. El manejador no debe reintentar el cargo.
Una anulación es un asiento nuevo, no una edición
Cuando hay que deshacer algo, no sobrescribo la línea original. Un asiento de compensación dice qué cambió y deja la primera línea legible. Una semana después todavía puedes responder qué se creía a las 14:02. Editar la fila solo responde qué se cree ahora, y lo responde sin testigo.
Si un reintento puede hacer que la operación ocurra dos veces, la operación no está terminada.
Nombra la clave antes de escribir el manejador
La clave no es “un hash de toda la petición” salvo que hayas comprobado qué cambia quien envía entre reintentos. Un proveedor de pagos reenviará el mismo id de evento con una marca de tiempo de entrega nueva. Si la marca está dentro de la clave, la segunda entrega parece nueva, y has construido el fallo que querías evitar. Uso el id que quien envía promete estable. Si no promete uno, exijo que quien llama envíe uno, y rechazo la llamada que llega sin él.
Luego escribo el fallo en el mismo sitio que el éxito. Un conflicto no es una página de error para quien envía. Es la respuesta esperada a un reintento. Quien envía debe poder parar. Si contesto 500, lo intentará una tercera vez, y una cuarta, y el incidente se vuelve una cola de trabajo idéntico.
No doy esto por hecho porque el camino feliz devolvió 200. La prueba vuelve a enviar la misma carga y afirma un solo efecto. Luego mata al worker entre el commit y la publicación y afirma que el mensaje sigue saliendo, una vez. Esas dos pruebas son más cortas que el incidente que sustituyen. Empiezo por ahí.
