02Architettura5 min

Lo stesso bug di concorrenza mi è comparso in tre progetti diversi

Un contatore che somma sotto carico perde incrementi, e uno che sottrae può addebitare due volte la stessa cosa. Quando il contatore è denaro, non è un dettaglio da sistemare dopo.

Di Salva Castells

Immagine di copertina: kisnikee, CC0.
In questo post

Lanci cento richieste concorrenti contro la stessa API key. Il contatore di utilizzi segna ottantasette. Non c'è nessun errore nei log, nessuna richiesta è fallita, nessuno si è lamentato. Semplicemente mancano tredici incrementi.

Quella falla mi è uscita tre volte, in progetti che non condividono nemmeno una riga di codice, sempre per lo stesso motivo: il contatore è stato scritto come se ci fosse un solo processo a scrivere.

Il pattern: leggere, sommare e scrivere in tre passi

Il codice che lo produce non sembra mai pericoloso. In una API di trascrizione, autenticare una chiave faceva due cose: controllare che non fosse revocata e aggiornare useCount e lastUsedAt. Leggere la riga, sommare uno in memoria, scrivere il risultato.

Con un utente alla volta funziona. Con worker in batch che picchiano sulla stessa chiave, no: tra la lettura e la scrittura entra un'altra richiesta, legge lo stesso valore e scrive lo stesso risultato. Due utilizzi, un incremento. Misurato con k6 a 10, 50 e 100 utenti virtuali, la perdita stava tra l'8 e il 14 per cento.

La correzione sta in una sentenza:

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'importante non è solo il use_count + 1, che è l'ovvio. È che il revoked_at IS NULL sta dentro lo stesso UPDATE. Prima il controllo della revoca e l'incremento erano due viaggi distinti, quindi esisteva una finestra in cui qualcuno revocava la chiave subito dopo averla validata e l'utilizzo veniva contato comunque. Con il filtro dentro, o la riga soddisfa la condizione e viene incrementata, o non torna niente e si lancia un 401. Non c'è stato intermedio da gestire.

Dopo il cambio, nove esecuzioni del test di concorrenza (tre livelli per tre ripetizioni), zero perdite.

Il caso opposto: addebitare due volte la stessa cosa

Il secondo caso era al contrario. In un motore di ricerca semantico, ogni ricerca consuma un'unità di quota e salva una riga con quanto è costata: modello di embedding, token, modello di rerank, token di input e di output.

C'era una deduplicazione: se ripetevi la stessa ricerca, la prenotazione restituiva duplicate, riutilizzava la riga originale e non addebitava quota un'altra volta.

Il problema è che la ricerca deduplicata veniva comunque eseguita. Richiamava gli embedding, richiamava il rerank, e il provider lo rifatturava. Quel costo non aveva dove andare: la riga originale aveva già il suo, e sovrascriverla avrebbe cancellato la spesa del primo tentativo. Quindi spariva. Il pannello di amministrazione diceva che le ricerche costavano meno di quanto costassero.

La tentazione era mettere un'altra riga nella stessa tabella con uno stato diverso. Cattiva idea, per due motivi concreti:

  • Ogni query di quota conta le righe senza filtrare. Una riga in più lì addebiterebbe quota che nessuno ha consumato.
  • Anche la ricerca del duplicato non filtra per stato. Prende la riga più recente con quell'hash. La riga nuova diventerebbe l'ancora della deduplicazione successiva, una riga che non è mai stata un'unità di quota.

La soluzione è stata una tabella a parte, unbilled_search_spend, con le stesse colonne di utilizzo e un riferimento alla prenotazione con cui si è risolto il duplicato. Quel riferimento è di audit, con ON DELETE set null, e non è un riferimento di quota. Il costo si vede nei report, ma non entra nel costo per ricerca addebitata, che è la cifra che si usa per decidere i prezzi.

Il terzo: due saldi letti separatamente

Il terzo è stato un SaaS di generazione di immagini. Ho aggiunto crediti giornalieri sopra quelli dell'abbonamento e la generazione ha smesso di funzionare per chi aveva solo quelli giornalieri. L'endpoint controllava profile.subCredits < estimatedCost e restituiva un 402, ignorando completamente dailyCredits. I crediti esistevano, venivano disegnati nella barra laterale, e il server diceva che non ce n'erano abbastanza.

Quello non è un bug di concorrenza, è di spezzare un contatore in due e dimenticarsene uno. Ma la radice è la stessa: il saldo ha smesso di essere un numero ed è diventato il risultato di un'operazione sparsa nel codice, fatta a mano in ogni punto.

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

Quella riga compare una volta nel controllo e un'altra nel calcolo del saldo restante che si restituisce al client. Due posti dove devi ricordarti di sommare. Il giorno in cui comparirà un terzo tipo di credito, saranno tre.

E quello che non è un bug, ma una politica non scritta

Il quarto caso è diverso ma appartiene alla stessa famiglia. Il webhook di invoice.paid schiacciava il saldo a ogni rinnovo:

balanceCents: pricing.balanceCents, // schiacciava il saldo precedente

Tutto quello non consumato del mese prima evaporava il giorno del rinnovo. Su quaranta utenti di un piano con 1499 centesimi di saldo, la mediana di quello che si perdeva era di 310 centesimi, e il novantesimo percentile arrivava a 890. Quasi il 60 per cento del piano buttato in alcuni casi.

Quello che lo rende interessante è che nessuno l'aveva deciso. Non era la politica, era la conseguenza di aver scritto un'assegnazione invece di una somma. Ora accumula con un tetto di tre mesi:

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

Il tetto non è cosmetico: senza, un account con uso sporadico accumula dodici mesi di saldo e poi lo spende in un picco, che è esattamente quello che non vuoi quando a monte paghi un provider di IA a consumo.

La regola

Ogni contatore che rappresenta denaro si scrive con tre cose dal primo giorno. Una: l'incremento e le sue condizioni vivono in una sola sentenza, SET x = x + 1 WHERE ..., mai in un read-modify-write. Due: se c'è deduplicazione, il costo reale ha un posto dove cadere anche se la quota non viene addebitata. Tre: la politica su quello che avanza al rinnovo è scritta da qualche parte, anche solo in un commento, perché se non lo è la scrive per te l'operatore di assegnazione.

Nessuna delle tre costa tempo quando si fa all'inizio. Tutte e tre costano un incidente quando si fanno dopo.