5. El caso de uso: compras sin transacción

La segunda parte es un caso de uso que vamos construyendo paso a paso. Los usuarios compran productos a través de una API HTTP: un POST con userId, productId y un comentario libre. Por cada compra hay que bajar el stock del producto. El userId es un simple nombre de usuario, para no complicar el ejemplo; los productos son una lista fija con precio y stock. Es deliberadamente la compra más burda posible: no hay carrito, no hay pago, se compra una unidad de un producto y listo.

La base tiene dos tablas, products (id, nombre, precio y stock) y purchases (id, usuario, producto, comentario y fecha). El servidor está en Node sin ningún framework: un GET /api/products, un GET y un POST en /api/purchases, y una UI estática mínima. Es la arquitectura distribuida más simple y más común que existe, un web server y una base, y conviene entenderla bien antes de cualquier otra. El Docker Compose de purchases/ levanta la base, la aplicación y un túnel de Cloudflare: un túnel temporal y gratuito que permite entrar a la aplicación desde Internet, para que más personas puedan usarla durante la clase.

El entorno, además, es hostil a propósito. El servidor está muy flojo de papeles y se reinicia seguido de forma inesperada: por falta de memoria, porque está en una máquina vieja o porque alguien lo desenchufa. No es un problema exclusivo de un servidor casero; en la nube los nodos tampoco viven para siempre. Las spot instances de AWS son baratas justamente porque AWS puede apagarlas cuando quiere, con apenas dos minutos de aviso. Y los usuarios compran desde una aplicación móvil, con una conexión intermitente: pasan por un túnel, entran a un ascensor. El pedido puede no llegar nunca, o llegar y que la respuesta se pierda. Para simular ese entorno, el código tiene una función failRandomly() que lanza una excepción la mitad de las veces, y que iremos moviendo a distintos puntos del código. La consigna es pensar que el proceso puede morir en cualquier línea y que, aun así, tiene que poder volver a levantarse sin romper las invariantes del sistema. Es la misma forma de pensar de la programación concurrente, con un agravante: allá había que considerar cómo se entrelazan las operaciones; acá, además, cualquiera de ellas puede fallar.

El branch 1-basic-service implementa la compra sin transacción, con la falla entre los dos writes:

await client.query('UPDATE products SET stock = stock - 1 WHERE id = $1', [productId]);

failRandomly();

await client.query(
    'INSERT INTO purchases (user_id, product_id, comment) VALUES ($1, $2, $3)',
    [userId, productId, comment || null],
);

Cada vez que la falla ocurre, el stock bajó pero la compra no existe. Lo vemos comparando el stock con la cantidad de compras de cada producto, que sumados deberían dar el stock inicial:

SELECT p.id, p.name, p.stock, count(c.id) AS compras
FROM products p LEFT JOIN purchases c ON c.product_id = p.id
GROUP BY p.id ORDER BY p.id;

En una prueba con diez intentos sobre el mismo producto, el stock bajó diez unidades y solo quedaron cuatro compras registradas.