Concorrência

Compra atômica no D1: o comentário do timestamp, o teste e o conserto

Marcone Santos · 4 de novembro de 2026 · 4 min de leitura

Um comentário no Instagram disse que usar timestamp como identificador de cobrança era um erro. Reproduzi em teste: dois pedidos no mesmo milissegundo cobravam o ouro duas vezes. O conserto usa changes() dentro da transação.

Um comentário num vídeo meu disse: "usar o timestamp como identificador pra cobrança é um erro". Fui conferir no código do jogo. Ele tinha razão. Este artigo mostra onde estava a falha, como reproduzi em teste e o conserto, com a ressalva de que eu nunca vi isso acontecer em produção. O vídeo curto sobre o mesmo caso está no meu Instagram e no YouTube.

Como a compra funciona

Dois jogadores clicam em "comprar" no mesmo item. Só existe um. Quem decide é o banco: o primeiro UPDATE com a condição sold_to IS NULL muda uma linha, o segundo muda zero. A compra roda como um batch do D1, que é uma transação, com dois comandos que importam:

// 1) tenta ficar com o anúncio: só passa se ainda não foi vendido
UPDATE market SET sold_to = ?, sold_at = ? WHERE id = ? AND guild_id = ? AND sold_to IS NULL AND seller != ? AND at > ?
// 2) cobra o ouro
UPDATE heroes SET save = json_set(save, '$.gold', json_extract(save, '$.gold') - (SELECT price FROM market WHERE id = ?1))
  WHERE uid = ?2 AND json_extract(save, '$.gold') >= (SELECT price FROM market WHERE id = ?1 AND sold_to = ?2 AND sold_at = ?3)

O segundo comando precisa pagar só se este pedido foi o que ganhou o anúncio. Eu resolvi isso comparando sold_at = ?3, onde ?3 era o instante do pedido (Date.now()). A ideia era boa em um ponto: o clique duplo, que chega com instantes diferentes, recebia "vendido" e não cobrava de novo. Foi um bug real de 13/10 que virou teste.

O erro do comentário

O instante é um identificador fraco. Se o segundo pedido do mesmo comprador trouxer exatamente o mesmo milissegundo do primeiro, ele perde a corrida (changes() do primeiro comando vira 0), mas o segundo comando acha uma linha com sold_to igual ao comprador e sold_at igual ao instante, e cobra de novo.

Reproduzi chamando a função real da compra com o SQLite em memória do teste, repetindo o pedido com o mesmo now:

1ª compra: ok     ouro depois 4645   (preço 4638)
2ª compra: sold   ouro depois 7      => COBROU DE NOVO

Com instantes diferentes (NOW + 1), não cobrava, e é por isso que o teste que eu já tinha passava.

Quão provável é

Na prática, baixo. O servidor lê Date.now() depois de ler o corpo da requisição, e um clique duplo costuma chegar com dezenas de milissegundos de diferença. Mas o relógio do Worker só avança em operações de entrada e saída, então dois pedidos tratados pelo mesmo isolate podem enxergar o mesmo valor. Isso é um raciocínio meu; não observei o caso em produção. A falha é de desenho: a guarda dependia de um valor que não é único.

O conserto

A guarda certa já estava dentro da transação. Em um batch, changes() devolve quantas linhas o comando anterior mudou. O primeiro comando mudou 1 linha se, e só se, este pedido ganhou o anúncio. O pagamento passa a depender disso:

UPDATE heroes SET save = json_set(save, '$.gold', json_extract(save, '$.gold') - (SELECT price FROM market WHERE id = ?1))
  WHERE uid = ?2 AND changes() = 1 AND json_extract(save, '$.gold') >= (SELECT price FROM market WHERE id = ?1 AND sold_to = ?2)

O conserto está em produção desde a versão 2026-11-04-dd20687 (commit dd20687). O instante continua sendo gravado em sold_at para o histórico, mas deixou de ser a prova da compra. O mesmo padrão changes() = 1 já protegia outros comandos do jogo, como o registro de saída do herói.

O teste

O teste novo repete a compra no mesmo milissegundo e confere que a segunda devolve "vendido" e o ouro não muda:

const r1 = await SOC.marketBuy(env, 'g', 'dbl', it2.id, NOW + 777), g1 = saveOf('dbl').gold;
const r2 = await SOC.marketBuy(env, 'g', 'dbl', it2.id, NOW + 777);
assert(r1.status === 'ok' && r2.status === 'sold' && saveOf('dbl').gold === g1);

Antes do conserto esse teste falha com a cobrança dupla. Depois, passa, junto com as outras 43 verificações da banca.

O que eu não sei

O que eu faria diferente

Teria escrito o teste do clique duplo com o instante repetido desde o primeiro bug, em vez de variar o instante só para fazê-lo passar. E teria perguntado a cada guarda "este valor é único?", em vez de "este valor é diferente do outro pedido de agora?".

Correção registrada

Este artigo nasceu de um comentário de leitor no Instagram. Se você achar outra falha, responda no vídeo e eu confiro.

Quer ver isso rodando? Os números e o código deste artigo vêm da Guilda do Ócio, um RPG idle cooperativo para devs, grátis e no navegador. Os vídeos desta série estão no YouTube e no Instagram.