Je vuurt honderd gelijktijdige verzoeken af op dezelfde API key. De gebruiksteller staat op zevenentachtig. Er staat geen enkele fout in de logs, geen enkel verzoek is mislukt, niemand heeft geklaagd. Er ontbreken gewoon dertien verhogingen.
Die fout is me drie keer overkomen, in projecten die geen regel code delen, steeds om dezelfde reden: de teller was geschreven alsof er maar één proces schreef.
Het patroon: lezen, optellen en schrijven in drie stappen
De code die dit veroorzaakt ziet er nooit gevaarlijk uit. In een transcriptie-API deed het authenticeren van een sleutel twee dingen: controleren dat hij niet was ingetrokken en useCount en lastUsedAt bijwerken. De rij lezen, er in het geheugen één bij optellen, het resultaat schrijven.
Met één gebruiker tegelijk werkt dat. Met batchworkers die tegen dezelfde sleutel aan beuken niet: tussen het lezen en het schrijven komt een ander verzoek binnen, leest dezelfde waarde en schrijft hetzelfde resultaat. Twee keer gebruik, één verhoging. Gemeten met k6 bij 10, 50 en 100 virtuele gebruikers lag het verlies tussen de 8 en 14 procent.
De fix past in één statement:
UPDATE api_key
SET use_count = use_count + 1,
last_used_at = now()
WHERE id = $1 AND revoked_at IS NULL
RETURNING use_count;
Het belangrijke is niet alleen de use_count + 1, dat is het voor de hand liggende deel. Het is dat de revoked_at IS NULL binnen dezelfde UPDATE staat. Eerder waren de intrekkingscontrole en de verhoging twee aparte reizen, dus er was een venster waarin iemand de sleutel introk net nadat hij was gevalideerd en het gebruik toch werd geteld. Met het filter erin voldoet de rij aan de voorwaarde en wordt hij verhoogd, of er komt niets terug en wordt een 401 gegooid. Er is geen tussenstaat om af te handelen.
Na de wijziging: negen runs van de concurrency-test (drie niveaus maal drie herhalingen), nul verlies.
Het omgekeerde geval: hetzelfde twee keer in rekening brengen
Het tweede geval was andersom. In een semantische zoekmachine verbruikt elke zoekopdracht één eenheid quota en schrijft een rij weg met wat hij kostte: embeddingsmodel, tokens, rerankmodel, invoer- en uitvoertokens.
Er was deduplicatie: als je dezelfde zoekopdracht herhaalde, gaf de reservering duplicate terug, hergebruikte de oorspronkelijke rij en bracht niet nog eens quota in rekening.
Het probleem is dat de gededupliceerde zoekopdracht wel degelijk werd uitgevoerd. Hij riep opnieuw embeddings aan, riep opnieuw de rerank aan, en de provider factureerde het opnieuw. Die kosten hadden nergens heen te gaan: de oorspronkelijke rij droeg de zijne al, en hem overschrijven zou de uitgaven van de eerste poging hebben gewist. Dus verdwenen ze. Het beheerpaneel zei dat de zoekopdrachten minder kostten dan ze kostten.
De verleiding was om er een extra rij in dezelfde tabel te zetten met een andere status. Slecht idee, om twee concrete redenen:
- Elke quotaquery telt rijen zonder filter. Een extra rij daar zou quota in rekening brengen die niemand had verbruikt.
- Het zoeken naar het duplicaat filtert ook niet op status. Het pakt de meest recente rij met die hash. De nieuwe rij zou het anker worden van de volgende deduplicatie, een rij die nooit een quota-eenheid is geweest.
De oplossing was een aparte tabel, unbilled_search_spend, met dezelfde gebruikskolommen en een verwijzing naar de reservering waarmee het duplicaat is opgelost. Die verwijzing is voor audit, met ON DELETE set null, en is geen quotaverwijzing. De kosten zijn zichtbaar in de rapporten, maar tellen niet mee in de kosten per in rekening gebrachte zoekopdracht, wat het cijfer is waarmee prijzen worden bepaald.
De derde: twee saldo's die apart worden gelezen
De derde was een SaaS voor beeldgeneratie. Ik voegde dagelijkse credits toe bovenop die van het abonnement en de generatie werkte niet meer voor wie alleen de dagelijkse had. Het endpoint controleerde profile.subCredits < estimatedCost en gaf een 402 terug, waarbij dailyCredits volledig werd genegeerd. De credits bestonden, ze werden getekend in de zijbalk, en de server zei dat er niet genoeg waren.
Dat is geen concurrency-bug, het is een teller in tweeën splitsen en er één vergeten. Maar de wortel is dezelfde: het saldo was geen getal meer, maar het resultaat van een berekening die over de code verspreid was, op elke plek met de hand gedaan.
const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);
Die regel staat één keer in de controle en één keer bij het berekenen van het resterende saldo dat aan de client wordt teruggegeven. Twee plekken waar je eraan moet denken op te tellen. De dag dat er een derde soort credit bij komt, worden het er drie.
En degene die geen bug is, maar een ongeschreven beleid
Het vierde geval is anders maar hoort bij dezelfde familie. De webhook van invoice.paid overschreef het saldo bij elke verlenging:
balanceCents: pricing.balanceCents, // overschreef het vorige saldo
Alles wat de vorige maand niet was verbruikt, verdampte op de dag van de verlenging. Over veertig gebruikers van een plan met 1499 cent saldo was de mediaan van wat verloren ging 310 cent, en het 90e percentiel kwam op 890. In sommige gevallen bijna 60 procent van het plan weggegooid.
Wat het interessant maakt, is dat niemand dat had besloten. Het was niet het beleid, het was het gevolg van een toewijzing schrijven in plaats van een optelling. Nu stapelt het op met een plafond van drie maanden:
const nextBalance = Math.min(
current.balanceCents + pricing.balanceCents,
pricing.balanceCents * 3
);
Het plafond is niet cosmetisch: zonder plafond spaart een account met sporadisch gebruik twaalf maanden saldo op en geeft dat dan in een piek uit, wat precies is wat je niet wilt als je stroomopwaarts een AI-provider per gebruik betaalt.
De regel
Elke teller die geld voorstelt, wordt vanaf dag één met drie dingen geschreven. Eén: de verhoging en haar voorwaarden leven in één enkel statement, SET x = x + 1 WHERE ..., nooit in een read-modify-write. Twee: als er deduplicatie is, hebben de echte kosten een plek om te landen, ook al wordt de quota niet in rekening gebracht. Drie: het beleid over wat er overblijft bij verlenging staat ergens opgeschreven, al is het in een commentaar, want als dat niet zo is, schrijft de toewijzingsoperator het voor je.
Geen van de drie kost tijd als je het aan het begin doet. Alle drie kosten ze een incident als je het achteraf doet.
