13. Semánticas de entrega

Queda el segundo pedido de producto: no inundar al usuario de notificaciones. Repasemos las semánticas de entrega. At-most-once: podemos perder datos pero nunca duplicarlos. At-least-once: podemos duplicar datos pero nunca perderlos. ¿Y exactly-once? No es real. Nuestra intuición dice que existe porque dentro de un programa, al llamar a una función, se ejecuta exactamente una vez; en un sistema distribuido, si enviamos un mensaje y no recibimos respuesta, no podemos saber qué pasó. Existen nombres cercanos, como exactly-once processing o effectively-once, que describen algo distinto: entregas at-least-once combinadas con deduplicación del lado de quien procesa, que es justamente la idempotencia.

Para las compras resolvimos la duplicación con idempotencia. Para las notificaciones no podemos: lamentablemente, muchas APIs de push notifications y de envío de mails no son idempotentes, así que un reintento después de un 500 puede duplicar el mensaje, porque no sabemos si el primero se envió. Frente a una dependencia no idempotente tenemos dos opciones, y hay que elegir una: at-most-once o at-least-once. Un at-least-once sin límite puede spamear a un usuario para siempre si algo falla en bucle, así que en la práctica se lo acota. Con un máximo de reintentos, si se agotan los intentos el mensaje puede perderse, y qué hacer en ese caso es una decisión particular de cada negocio.

8-notification-delivery-semantics lo implementa. El notifier tiene un failRandomly() después de enviar la notificación y antes del ACK: el proceso muere, Docker lo reinicia (restart: unless-stopped) y Postgres le reenvía todo desde el último ACK. Para acotar los envíos, el intento se registra antes de enviar en una tabla de intentos cuya clave es el id de la compra, que ya es único; si el proceso muere después de enviar, el intento igual cuenta. Esa tabla no necesita estar ligada a la de compras, e incluso podría vivir en otro tipo de base o de storage.

CREATE TABLE notification_attempts (
    purchase_id int PRIMARY KEY,
    attempts    int NOT NULL
);
const attempts = await registerAttempt(purchase); // INSERT ... ON CONFLICT DO UPDATE ... RETURNING attempts
if (attempts > MAX_ATTEMPTS) {
    return;                                       // se agotaron los intentos: se descarta
}
await notify(purchase);
failRandomly();                                   // enviada pero sin ACK: al reiniciar se reenvía

En los logs del notifier se ven los crashes y, cuando una compra agota sus tres intentos, que se descarta. En ntfy llegan notificaciones duplicadas, pero nunca más de tres por compra.