Numa plataforma de jogos com dinheiro real, o pior bug não é o que derruba o site: é o que credita o mesmo depósito duas vezes, ou deixa uma aposta sair de graça. Nenhum dos dois aparece nos logs como erro. Neste texto eu conto como estruturei a carteira da plataforma do Girowin para que isso não aconteça, e o que ainda está no caminho.
De onde vem a duplicidade
- Provedores de jogos reenviam a mesma aposta, o mesmo ganho ou o mesmo cancelamento quando não recebem resposta a tempo.
- O crédito de um depósito viaja por RPC no RabbitMQ. Se a resposta se perde depois do commit, quem chamou tenta de novo.
- Duas apostas do mesmo jogador no mesmo milissegundo leem o mesmo saldo.
- Jobs agendados rodam em mais de uma réplica.
- Um processo pode cair no meio do fluxo: um serviço commita, o outro não chega a registrar.
Alguns desses casos aconteceram de verdade e moldaram o desenho, como um crédito que se repetia num retry porque acontecia antes do registro idempotente, e um crédito de depósito que falhava em silêncio.
A regra: idempotência no banco, não na disciplina
Em vez de confiar que cada handler lembre de checar se já processou um evento, eu coloquei a garantia numa constraint. Cada movimento da carteira tem uma chave única formada pela carteira, pelo tipo do movimento e pelo id externo de quem originou o movimento: o id do depósito, o id da transação do provedor, o id da aposta.
Quando um reenvio esbarra nessa chave, o erro de unicidade vira um erro de duplicidade explícito e a transação inteira volta, saldo incluído. O lançamento é gravado com o mesmo EntityManager que alterou o saldo, e o saldo novo sai de um UPDATE com RETURNING: o "antes" é derivado dele, nunca de uma leitura anterior que outra transação possa ter mudado.
No crédito de depósito, um reenvio não falha: o serviço devolve o recibo do lançamento original, com o saldo antes e depois, e recusa se usuário, marca ou valor não baterem.
O caminho de um depósito
O diagrama mostra o caminho de ponta a ponta. Cada etapa é idempotente por conta própria.
sequenceDiagram
participant G as Gateway de pagamento
participant C as casino
participant Q as RabbitMQ
participant W as gaming
participant DB as PostgreSQL
G->>C: retorno do pagamento (via fila)
C->>C: confirma o depósito (status já confirmado = ignora)
C->>C: evento interno deposit.confirmed
C->>Q: RPC de crédito com o id do depósito como chave
Q->>W: crédito
W->>DB: saldo e lançamento na mesma transação
alt reenvio do mesmo depósito
DB-->>W: chave única já existe
W-->>C: recibo do lançamento original, nada creditado
else primeira vez
W-->>C: saldo antes e depois
end
C->>C: marca CREDITED ou agenda retry com backoff
- Callback: o mesmo status já aplicado é ignorado, um depósito confirmado nunca é rebaixado por um status tardio, e uma confirmação tardia depois de expirado prevalece.
- Evento interno: o deposit.confirmed só é emitido na transição para confirmado; se o depósito já foi creditado, os efeitos que não são idempotentes, como promoção e afiliados, são pulados.
- Crédito: o serviço relê o depósito do banco, pula se já foi creditado e envia o id do depósito como chave; se falhar, grava a falha e agenda um retry, sem lançar exceção.
- Retry: o job usa o id do depósito como id, então nunca existem dois retries do mesmo depósito; são 5 tentativas com backoff exponencial a partir de 30 s, e o job falho fica guardado. Esgotadas as tentativas, o depósito vira um caso para uma pessoa olhar.
- Expiração: um UPDATE condicional só expira o que ainda está pendente, sem atropelar uma confirmação concorrente.
Apostas e saques concorrentes
O débito de aposta lê a conta com FOR UPDATE e só então divide o valor entre saldo real e bônus. O saque usa um advisory lock por saque, trava a conta e grava um recibo de débito na mesma transação; um replay do mesmo saque devolve o recibo original depois de conferir usuário, marca, valor e carteira. Todos os caminhos seguem a mesma ordem de locks, para não criar deadlocks.
Dinheiro não é float
Todo o caminho do dinheiro usa decimal.js, e os valores vão para colunas numeric(18,8). No débito de aposta, o valor efetivo é derivado do saldo já arredondado para 8 casas, para que carteira, aposta e bônus concordem exatamente com o que o banco grava.
Conferir a cadeia
Cada lançamento guarda o saldo antes e depois e um número de sequência atribuído no INSERT, com o lock da conta em mãos. Uso a sequência e não a data porque now() marca o início da transação e inverteria mutações concorrentes.
Com isso, conferir a carteira vira uma consulta: o saldo depois de um lançamento tem de ser igual ao saldo antes do próximo, e o último lançamento tem de bater com a conta. Um job diário roda essa conferência sobre as últimas 25 horas, na réplica de leitura, e só detecta: nunca corrige. A correção de um problema real entra como um ajuste explícito, registrado por um funcionário, porque a cadeia é a evidência e não se edita.
Callbacks de provedores
Os callbacks são validados por HMAC-SHA256 sobre o corpo cru da requisição, com comparação em tempo constante. Re-serializar o JSON antes de validar quebraria a assinatura de forma intermitente. Cada tipo de movimento usa o id certo do provedor como chave: o da transação na aposta, o do resultado no ganho e o da aposta no estorno.
Jobs em várias réplicas
Os crons rodam num papel de worker separado da API, coordenados por um lease no Redis por marca, serviço e job, renovado a cada 20 segundos e liberado de forma atômica por scripts Lua. Isso não é exactly-once, e eu não finjo que é: a coordenação reduz a concorrência, e a idempotência de cada efeito garante o resultado.
Testes
A regressão da carteira roda no CI contra um PostgreSQL real em cada PR. Os testes de integração cobrem, entre outros, saldo e lançamento entrando juntos, id externo repetido sem mudar o saldo, a cadeia fechando, a recuperação de um crédito depois de o gaming commitar sem creditar duas vezes, oito apostas duplicadas concorrentes virando uma só, e cerca de 25 cenários de saque com resposta perdida e replays concorrentes.
O que ainda falta
- O ledger está em implantação: já roda em staging e entra em produção por etapas, com o histórico antigo trazido por backfill explícito.
- A chave única não protege lançamentos sem id externo, que ainda podem vir de produtores antigos.
- Falhas de serialização e deadlocks no caminho do dinheiro ainda sobem como erro; quem tenta de novo é a fila ou o provedor.
- A conferência diária só registra no log; falta transformá-la em alerta.
