Você dispara cem requisições concorrentes contra a mesma API key. O contador de usos marca oitenta e sete. Não há nenhum erro nos logs, nenhuma requisição falhou, ninguém reclamou. Simplesmente faltam treze incrementos.
Essa falha me apareceu três vezes, em projetos que não compartilham uma linha de código, sempre pelo mesmo motivo: o contador foi escrito como se houvesse apenas um processo escrevendo.
O padrão: ler, somar e escrever em três passos
O código que produz isso nunca parece perigoso. Numa API de transcrição, autenticar uma chave fazia duas coisas: verificar que ela não estivesse revogada e atualizar useCount e lastUsedAt. Ler a linha, somar um em memória, escrever o resultado.
Com um usuário por vez funciona. Com workers em batch batendo contra a mesma chave, não: entre a leitura e a escrita entra outra requisição, lê o mesmo valor e escreve o mesmo resultado. Dois usos, um incremento. Medido com k6 a 10, 50 e 100 usuários virtuais, a perda ficava entre 8 e 14 por cento.
O conserto cabe numa sentença:
UPDATE api_key
SET use_count = use_count + 1,
last_used_at = now()
WHERE id = $1 AND revoked_at IS NULL
RETURNING use_count;
O importante não é só o use_count + 1, que é o óbvio. É que o revoked_at IS NULL está dentro do mesmo UPDATE. Antes, a verificação de revogação e o incremento eram duas viagens distintas, então existia uma janela em que alguém revogava a chave logo depois de validá-la e o uso era contado do mesmo jeito. Com o filtro dentro, ou a linha cumpre a condição e é incrementada, ou não volta nada e se lança um 401. Não há estado intermediário para gerenciar.
Depois da mudança, nove rodadas do teste de concorrência (três níveis por três repetições), zero perdas.
O caso contrário: cobrar duas vezes a mesma coisa
O segundo caso foi ao contrário. Num buscador semântico, cada busca consome uma unidade de cota e grava uma linha com o que custou: modelo de embeddings, tokens, modelo de rerank, tokens de entrada e saída.
Havia deduplicação: se você repetisse a mesma busca, a reserva devolvia duplicate, reutilizava a linha original e não cobrava cota de novo.
O problema é que a busca deduplicada era executada mesmo assim. Chamava embeddings de novo, chamava o rerank de novo, e o provedor faturava de novo. Esse custo não tinha para onde ir: a linha original já carregava o seu, e sobrescrevê-la teria apagado o gasto da primeira tentativa. Então desaparecia. O painel de administração dizia que as buscas custavam menos do que custavam.
A tentação era colocar outra linha na mesma tabela com um estado diferente. Má ideia, por dois motivos concretos:
- Toda consulta de cota conta linhas sem filtrar. Uma linha extra ali cobraria cota que ninguém consumiu.
- A busca do duplicado também não filtra por estado. Pega a linha mais recente com aquele hash. A linha nova viraria a âncora da deduplicação seguinte, uma linha que nunca foi uma unidade de cota.
A solução foi uma tabela à parte, unbilled_search_spend, com as mesmas colunas de uso e uma referência à reserva com a qual o duplicado foi resolvido. Essa referência é de auditoria, com ON DELETE set null, e não é uma referência de cota. O custo aparece nos relatórios, mas não entra no custo por busca cobrada, que é o número usado para decidir preços.
O terceiro: dois saldos lidos em separado
O terceiro foi um SaaS de geração de imagens. Adicionei créditos diários em cima dos de assinatura e a geração parou de funcionar para quem só tinha os diários. O endpoint verificava profile.subCredits < estimatedCost e devolvia um 402, ignorando completamente dailyCredits. Os créditos existiam, eram pintados na barra lateral, e o servidor dizia que não havia o suficiente.
Esse não é um bug de concorrência, é de partir um contador em dois e esquecer de um. Mas a raiz é a mesma: o saldo deixou de ser um número e passou a ser o resultado de uma operação espalhada pelo código, feita na mão em cada lugar.
const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);
Essa linha aparece uma vez na verificação e outra ao calcular o saldo restante devolvido ao cliente. Dois lugares onde é preciso lembrar de somar. No dia em que aparecer um terceiro tipo de crédito, serão três.
E o que não é um bug, e sim uma política não escrita
O quarto caso é diferente, mas pertence à mesma família. O webhook de invoice.paid atropelava o saldo em cada renovação:
balanceCents: pricing.balanceCents, // atropelava o saldo anterior
Tudo o que não fosse consumido do mês anterior evaporava no dia da renovação. Sobre quarenta usuários de um plano com 1499 centavos de saldo, a mediana do que se perdia era de 310 centavos, e o percentil 90 chegava a 890. Quase 60 por cento do plano jogado fora em alguns casos.
O que torna isso interessante é que ninguém tinha decidido aquilo. Não era a política, era a consequência de ter escrito uma atribuição em vez de uma soma. Agora acumula com teto de três meses:
const nextBalance = Math.min(
current.balanceCents + pricing.balanceCents,
pricing.balanceCents * 3
);
O teto não é cosmético: sem ele, uma conta com uso esporádico junta doze meses de saldo e depois gasta tudo num pico, que é exatamente o que você não quer quando lá em cima paga um provedor de IA por uso.
A regra
Todo contador que represente dinheiro se escreve com três coisas desde o primeiro dia. Uma: o incremento e suas condições vivem numa única sentença, SET x = x + 1 WHERE ..., nunca num read-modify-write. Duas: se há deduplicação, o custo real tem um lugar onde cair mesmo que a cota não seja cobrada. Três: a política sobre o que sobra na renovação está escrita em algum lugar, nem que seja num comentário, porque se não estiver o operador de atribuição a escreve por você.
Nenhuma das três custa tempo quando é feita no começo. As três custam um incidente quando são feitas depois.
