02Arquitectura8 min

Mis contadores de dinero mentían y ninguno lanzaba un error

Incrementos que se pierden bajo carga, un coste que desaparece al deduplicar, un saldo que se evapora en cada renovación. Cuatro bugs en tres proyectos, y todos por lo mismo.

Por Salva Castells

Imagen de portada: kisnikee, CC0.
En este post

Lanzo cien peticiones concurrentes contra la misma API key y el contador de usos se queda en ochenta y siete. Ni un error en los logs, ninguna petición fallida, nadie se ha quejado. Faltan trece incrementos y ya está.

Si llevas tiempo en esto seguramente estás pensando "read-modify-write, de primero de bases de datos". Y sí. Lo que me molesta no es no saberlo, es que lo sé y aun así lo he escrito mal en tres proyectos que no comparten ni una línea de código. Así que este post no va de explicar qué es una condición de carrera. Va de por qué sigo cayendo, y de otros tres bugs de la misma familia que ni siquiera son de concurrencia pero salen del mismo error de fondo.

El clásico, medido

En una API de transcripción, autenticar una clave hacía dos cosas: comprobar que no estuviera revocada y actualizar useCount y lastUsedAt. Leía la fila, sumaba uno en memoria y escribía el resultado. Con un usuario a la vez no hay problema. Con workers en batch golpeando la misma clave, entre la lectura y la escritura se cuela otra petición, lee el mismo valor y escribe el mismo resultado. Dos usos, un incremento.

Lo medí con k6 a 10, 50 y 100 usuarios virtuales. Se perdían entre un 8 y un 14 por ciento de los incrementos. El arreglo es una sola sentencia:

UPDATE api_key
SET use_count = use_count + 1,
    last_used_at = now()
WHERE id = $1 AND revoked_at IS NULL
RETURNING use_count;

El use_count + 1 es lo que todo el mundo conoce. La parte que a mí me costó ver es el revoked_at IS NULL dentro del mismo UPDATE. Antes, comprobar la revocación e incrementar eran dos viajes separados a la base de datos, y en medio había un hueco: alguien revocaba la clave justo después de validarla y el uso se contaba igual. Ahora, o la fila cumple la condición y se incrementa, o no devuelve nada y respondo 401. No hay un estado intermedio que tenga que gestionar.

Después del cambio hice nueve pasadas del test (tres niveles de carga, tres repeticiones cada uno) y no se perdió ningún incremento.

Lo que me llevo de aquí, más que el UPDATE, es una forma de revisar código: cada vez que veo un if que lee un valor de la base de datos y, unas líneas más abajo, un update que escribe algo que depende de ese valor, me pregunto qué pasa si entre las dos cosas se cuela otra petición. Normalmente la condición tiene que ir en el WHERE.

Esto vale igual para restar. Si compruebas "¿le llega el saldo?" en un sitio y descuentas en otro, has escrito el mismo bug en la otra dirección: dos peticiones simultáneas pasan la comprobación y el saldo acaba en negativo. La versión segura es la misma idea:

UPDATE user_billing
SET balance_cents = balance_cents - $2
WHERE user_id = $1 AND balance_cents >= $2
RETURNING balance_cents;

Si no devuelve fila, no hay saldo. Hay un matiz que no tengo bien resuelto: con los modelos de IA muchas veces no sabes lo que cuesta una llamada hasta que termina. Lo correcto sería reservar una estimación con esta sentencia y ajustar al final, y eso ya es un pequeño sistema de reservas. Lo fácil es aceptar que el saldo quede un poco en negativo y descontarlo después, y reconozco que me tienta más de lo que debería.

El coste que desaparecía al deduplicar

Este es el que más me gusta, porque no se parece a ningún ejemplo de libro.

En un buscador semántico, cada búsqueda consume una unidad de cuota y guarda una fila con lo que ha costado: modelo de embeddings, tokens, modelo de rerank, tokens de entrada y de salida. Había deduplicación: si repetías la misma búsqueda, la reserva devolvía duplicate, reutilizaba la fila original y no te cobraba cuota otra vez.

El problema es que la búsqueda deduplicada se ejecutaba igual. Volvía a llamar a embeddings, volvía a llamar al rerank y el proveedor me lo volvía a cobrar. Y ese coste no se apuntaba en ningún sitio: la fila original ya tenía el suyo, y sobrescribirla habría borrado el gasto del primer intento. Resultado: el panel de administración decía que las búsquedas costaban menos de lo que costaban de verdad. Es el peor tipo de dato erróneo, porque te lo crees.

Lo primero que pensé fue meter otra fila en la misma tabla con otro estado. Por suerte antes de hacerlo miré quién leía esa tabla. Todas las consultas de cuota contaban filas sin filtrar por estado, así que una fila extra habría cobrado una cuota que nadie había consumido. Y la búsqueda del duplicado cogía la fila más reciente con ese hash, también sin filtrar, con lo que la fila nueva habría pasado a ser la referencia de la siguiente deduplicación sin haber sido nunca una unidad de cuota. Un campo status solo protege si todos los que leen la tabla lo filtran, y nadie te avisa cuando alguien se olvida.

Acabé creando una tabla aparte, unbilled_search_spend, con las mismas columnas de uso y una referencia a la reserva con la que se resolvió el duplicado. Esa referencia es solo de auditoría, con ON DELETE set null, y no cuenta para la cuota. El coste aparece en los informes, pero no entra en el coste por búsqueda cobrada, que es la cifra con la que decido los precios.

Sé que alguien me dirá que lo correcto es no ejecutar la búsqueda duplicada y devolver la respuesta cacheada. Estoy de acuerdo, y seguramente se acabará haciendo. Pero mientras el sistema haga algo que cuesta dinero, ese coste tiene que quedar apuntado en algún sitio, aunque no se cobre. Un gasto que no apuntas no es que no exista: te llega en la factura del proveedor.

Dos bolsas y una comprobación que solo miraba una

El tercero no es de concurrencia, pero lo pongo porque la raíz es la misma. En un SaaS de generación de imágenes añadí créditos diarios gratuitos además de los de suscripción, y la generación dejó de funcionar para quien solo tenía los diarios. El endpoint comprobaba profile.subCredits < estimatedCost, devolvía 402 y se olvidaba de dailyCredits. Los créditos existían, se veían en la barra lateral, y el servidor decía que no había suficientes.

const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);

Esa suma aparece una vez en la comprobación y otra al calcular el saldo que se devuelve al cliente. Son dos sitios donde hay que acordarse de sumar, y el día que haya un tercer tipo de crédito serán tres. En cuanto el saldo deja de ser una columna y pasa a ser una cuenta que haces tú en el código, cada sitio que la repite es otro sitio donde te puedes equivocar. Lo que habría que hacer es tener una sola función, o una columna generada. Todavía no lo he hecho.

El que nadie decidió

El último ni siquiera es un bug en sentido estricto. El webhook de invoice.paid sobrescribía el saldo en cada renovación:

balanceCents: pricing.balanceCents, // pisaba el balance anterior

Todo lo que no se había gastado el mes anterior desaparecía el día de la renovación. Sobre cuarenta usuarios de un plan con 1499 céntimos de saldo, la mediana de lo que se perdía era de 310 céntimos y el percentil 90 llegaba a 890. En algunos casos, casi el 60 por ciento del plan.

Lo que me hace gracia es que nadie lo había decidido así. No era una política, era un = donde tenía que haber un +. Ahora el saldo se acumula, con un tope de tres meses:

const nextBalance = Math.min(
  current.balanceCents + pricing.balanceCents,
  pricing.balanceCents * 3
);

El tope no está de adorno. Sin él, una cuenta con uso esporádico acumula doce meses de saldo y luego lo quema de golpe, y es justo lo que no quieres cuando tú pagas por uso a un proveedor de IA.

Mientras escribo esto me doy cuenta de que este arreglo tiene la misma forma que el primer bug del post: lee current y luego escribe. Una renovación no suele coincidir con un gasto, pero puede, y si coinciden gana el último que escribe. Stripe además puede entregar el mismo evento más de una vez, y sin registrar qué eventos has procesado la acumulación se aplica dos veces (el tope limita el daño, pero no lo evita). Así estaría mejor:

UPDATE user_billing
SET balance_cents = LEAST(balance_cents + $2, $2 * 3)
WHERE user_id = $1;

Y además guardar el id del evento para no aplicarlo dos veces. Lo tengo apuntado.

Por qué sigo cayendo

Creo que es porque el ORM hace que leer, calcular y escribir parezca lo natural. Traes el objeto, cambias un campo, llamas a update. Es cómodo, se lee bien y no da problemas hasta que hay dos peticiones a la vez, o dos tipos de crédito, o una tabla que otros también leen.

Mi regla ahora, cuando el contador es dinero, es desconfiar de cualquier valor que haya pasado por la memoria de la aplicación antes de volver a la base de datos. Si puedo escribir la operación como una sola sentencia con la condición en el WHERE, la escribo así. Si no puedo, por lo menos quiero saber por qué no, y tenerlo escrito en un comentario.