02Arquitectura5 min

El mateix bug de concurrència m'ha aparegut en tres projectes diferents

Un comptador que suma sota càrrega perd increments, i un que resta pot cobrar dues vegades el mateix. Quan el comptador és diners, això no és un detall que s'arregli després.

Per Salva Castells

Imatge de portada: kisnikee, CC0.
En aquest post

Llances cent peticions concurrents contra la mateixa API key. El comptador d'usos marca vuitanta-set. No hi ha cap error als logs, cap petició no ha fallat, ningú no s'ha queixat. Simplement falten tretze increments.

Aquesta fallada m'ha sortit tres vegades, en projectes que no comparteixen ni una línia de codi, sempre per la mateixa raó: el comptador es va escriure com si només hi hagués un procés escrivint.

El patró: llegir, sumar i escriure en tres passos

El codi que ho produeix mai no sembla perillós. En una API de transcripció, autenticar una clau feia dues coses: comprovar que no estigués revocada i actualitzar useCount i lastUsedAt. Llegir la fila, sumar un a memòria, escriure el resultat.

Amb un usuari a la vegada funciona. Amb workers en batch picant contra la mateixa clau, no: entre la lectura i l'escriptura entra una altra petició, llegeix el mateix valor i escriu el mateix resultat. Dos usos, un increment. Mesurat amb k6 a 10, 50 i 100 usuaris virtuals, la pèrdua era d'entre el 8 i el 14 per cent.

L'arreglada cap en una sentència:

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

L'important no és només el use_count + 1, que és l'obvi. És que el revoked_at IS NULL és dins del mateix UPDATE. Abans la comprovació de revocació i l'increment eren dos viatges diferents, així que hi havia una finestra en què algú revocava la clau just després de validar-la i l'ús es comptava igual. Amb el filtre a dins, o la fila compleix la condició i s'incrementa, o no torna res i es llança un 401. No hi ha estat intermedi per gestionar.

Després del canvi, nou execucions del test de concurrència (tres nivells per tres repeticions), zero pèrdues.

El cas contrari: cobrar dues vegades el mateix

El segon cas va ser a l'inrevés. En un cercador semàntic, cada cerca consumeix una unitat de quota i desa una fila amb el que va costar: model d'embeddings, tokens, model de rerank, tokens d'entrada i sortida.

Hi havia deduplicació: si repeties la mateixa cerca, la reserva retornava duplicate, reutilitzava la fila original i no cobrava quota una altra vegada.

El problema és que la cerca deduplicada sí que s'executava. Tornava a cridar embeddings, tornava a cridar el rerank, i el proveïdor tornava a facturar-ho. Aquell cost no tenia on anar: la fila original ja duia el seu, i sobreescriure-la hauria esborrat la despesa del primer intent. Així que desapareixia. El panell d'administració deia que les cerques costaven menys del que costaven.

La temptació era ficar una altra fila a la mateixa taula amb un estat diferent. Mala idea, i per dos motius concrets:

  • Tota consulta de quota compta files sense filtrar. Una fila extra allà cobraria quota que ningú no havia consumit.
  • La cerca del duplicat tampoc no filtra per estat. Agafa la fila més recent amb aquell hash. La fila nova es convertiria en l'àncora de la deduplicació següent, una fila que mai no va ser una unitat de quota.

La solució va ser una taula a part, unbilled_search_spend, amb les mateixes columnes d'ús i una referència a la reserva amb què es va resoldre el duplicat. Aquesta referència és d'auditoria, amb ON DELETE set null, i no és una referència de quota. El cost es veu als informes, però no entra en el cost per cerca cobrada, que és la xifra que es fa servir per decidir preus.

El tercer: dos saldos que es llegeixen per separat

El tercer va ser un SaaS de generació d'imatges. Vaig afegir crèdits diaris a sobre dels de subscripció i la generació va deixar de funcionar per a qui només tenia els diaris. L'endpoint comprovava profile.subCredits < estimatedCost i retornava un 402, ignorant del tot dailyCredits. Els crèdits existien, es pintaven a la barra lateral, i el servidor deia que no n'hi havia prou.

Aquest no és un bug de concurrència, és de partir un comptador en dos i oblidar-se'n d'un. Però l'arrel és la mateixa: el saldo va deixar de ser un número i va passar a ser el resultat d'una operació repartida pel codi, feta a mà a cada lloc.

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

Aquesta línia apareix una vegada a la comprovació i una altra en calcular el saldo restant que es retorna al client. Dos llocs on cal recordar-se de sumar. El dia que aparegui un tercer tipus de crèdit, en seran tres.

I el que no és un bug, sinó una política sense escriure

El quart cas és diferent però pertany a la mateixa família. El webhook d'invoice.paid trepitjava el balanç a cada renovació:

balanceCents: pricing.balanceCents, // trepitjava el balanç anterior

Tot el que no s'havia consumit del mes anterior s'evaporava el dia de la renovació. Sobre quaranta usuaris d'un pla amb 1499 cèntims de saldo, la mediana del que es perdia era de 310 cèntims, i el percentil 90 arribava a 890. Gairebé el 60 per cent del pla llençat en alguns casos.

El que ho fa interessant és que ningú no ho havia decidit. No era la política, era la conseqüència d'haver escrit una assignació en comptes d'una suma. Ara acumula amb un sostre de tres mesos:

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

El sostre no és cosmètic: sense ell, un compte amb ús esporàdic ajunta dotze mesos de saldo i després el gasta en un pic, que és exactament el que no vols quan aigües amunt pagues un proveïdor d'IA per ús.

La regla

Tot comptador que representi diners s'escriu amb tres coses des del primer dia. Una: l'increment i les seves condicions viuen en una sola sentència, SET x = x + 1 WHERE ..., mai en un read-modify-write. Dues: si hi ha deduplicació, el cost real té un lloc on caure encara que la quota no es cobri. Tres: la política sobre el que sobra en renovar està escrita en algun lloc, encara que sigui en un comentari, perquè si no ho està l'escriu l'operador d'assignació per tu.

Cap de les tres no costa temps quan es fa al principi. Totes tres costen un incident quan es fan després.