Compra atômica no D1: o comentário do timestamp, o teste e o conserto
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
- Se isso já aconteceu com algum jogador. Não vi evidência nos dados que olhei.
- Se o D1 em produção avalia
changes()de forma idêntica ao SQLite do teste dentro de umbatch. O padrão já é usado em outros comandos do jogo, mas o artigo só prova o comportamento no teste local.
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.