Tolerância a Falhas
Esta pagina explica por que o sistema ProCases e matematicamente impossivel de apresentar race conditions, inconsistencia de saldo, duplicacao de dados ou perda de transacoes. Cada mecanismo de protecao e documentado com referencia ao codigo real.
7 Camadas de Protecao
O sistema opera com 7 camadas de protecao que se complementam. Cada camada cobre falhas que as outras poderiam deixar passar, formando uma defesa em profundidade.
| Camada | Mecanismo | O que previne | Onde atua |
|---|---|---|---|
| 1 | Distributed Lock (Redlock) | Operacoes simultaneas no mesmo recurso | Redis |
| 2 | Transacao Atomica (Prisma) | Atualizacoes parciais no banco | PostgreSQL |
| 3 | Status do Inventario (Enum) | Uso simultaneo do mesmo item | PostgreSQL |
| 4 | Source of Truth (SUM de TX) | Saldo desatualizado (TOCTOU) | PostgreSQL |
| 5 | Webhook Idempotente | Deposito creditado 2x | Aplicacao |
| 6 | Maquinas de Estado (Enums) | Transicoes de estado invalidas | PostgreSQL |
| 7 | Unique Constraints | Duplicacao de dados criticos | PostgreSQL |
Camada 1: Distributed Locks (Redlock)
Toda operacao que altera saldo, status de item ou estado de batalha adquire um lock distribuido antes de iniciar. Apenas uma thread por vez pode executar operacoes no mesmo recurso.
4 tipos de lock implementados
| Lock | Chave Redis | TTL | Protege |
|---|---|---|---|
| User Balance | lock:user:balance:{userId} | 10s | Qualquer operacao de saldo |
| Transaction | lock:transaction:create | 5s | Criacao de double-entry |
| Withdrawal | lock:withdrawal:item:{itemId} | 30s | Saque do mesmo item |
| Swap | lock:swap:{swapId} | 5s | Aceitar mesma troca |
Arquivo: src/infrastructure/locks/lock.service.ts
Configuracao:
- 10 tentativas de aquisicao com 200ms de delay + 200ms de jitter aleatorio
- Auto-extensao se restam menos de 500ms (previne expiracao durante operacao)
- Drift factor de 0.01 (1% tolerancia de clock entre servidores)
Onde cada lock e usado
User Balance Lock (15 use cases):
| Use Case | Arquivo | O que protege |
|---|---|---|
| Abertura de Caixa | open-case.use-case.ts:60 | Debito do preco + criacao do item |
| Upgrade | perform-upgrade.use-case.ts:51 | Debito + roll + resultado |
| Criar Batalha | create-battle.use-case.ts:50 | Debito da entry fee |
| Entrar em Batalha | join-battle.use-case.ts:57 | Debito da entry fee |
| Vender Item | sell-inventory-item.use-case.ts:38 | Credito do valor do item |
| Vender Multiplos | sell-multiple-items.use-case.ts:45 | Credito total |
| Vender Todos | sell-all-items.use-case.ts:28 | Credito total |
| Criar Swap | create-swap.use-case.ts:38 | Lock dos itens oferecidos |
| Site Swap | perform-site-swap.use-case.ts:79 | Debito/credito do swap |
| Comprar Tickets | buy-raffle-tickets.use-case.ts:41 | Debito do custo |
| Admin Ajuste | update-user-balance.use-case.ts:24 | Ajuste administrativo |
Withdrawal Lock: request-withdraw-item.use-case.ts:100
Swap Lock com Nested Locks: accept-swap.use-case.ts:52-54
lock:swap:{swapId}
└── lock:user:balance:{userId1}
└── lock:user:balance:{userId2}
└── Prisma $transaction (troca de itens)O que acontece se duas threads competem
Thread 1: withUserBalanceLock(user:123) → ADQUIRIU
Thread 2: withUserBalanceLock(user:123) → AGUARDANDO...
(retry 1/10, 200ms)
(retry 2/10, 400ms)
...
Thread 1: Verifica saldo → Debita → Commit → RELEASE lock
Thread 2: withUserBalanceLock(user:123) → ADQUIRIU (retry 3)
Thread 2: Verifica saldo → Saldo ja foi debitado → Logica procede normalmenteResultado: Operacoes sao serializadas. Nunca executam em paralelo para o mesmo usuario.
Camada 2: Transacoes Atomicas (Prisma)
Dentro de cada lock, operacoes criticas rodam em prisma.$transaction(). Se qualquer passo falhar, tudo e revertido automaticamente pelo PostgreSQL.
O padrao: Lock → Transaction → Commit → Release
1. ACQUIRE Redlock (Redis)
2. BEGIN Prisma $transaction (PostgreSQL)
3. Verificar saldo (SELECT SUM...)
4. Criar DEBIT transaction
5. Criar CREDIT transaction
6. Atualizar cache de saldo
7. Criar registro (CaseOpening, BattlePlayer, etc)
8. COMMIT (PostgreSQL)
9. RELEASE Redlock (Redis)Se o passo 6 falhar, os passos 3-5 sao revertidos. Se o Redis cair entre os passos 1 e 9, o lock expira apos o TTL e a proxima operacao pode prosseguir.
Configuracao
| Parametro | Valor | Significado |
|---|---|---|
maxWait | 10 segundos | Tempo maximo esperando conexao do pool |
timeout | 30 segundos | Tempo maximo de execucao da transacao |
| Isolation | READ COMMITTED | Padrao PostgreSQL, previne dirty reads |
Arquivo: src/infrastructure/database/prisma.service.ts
15 use cases usam transacoes atomicas
| Operacao | O que e atomico |
|---|---|
| Abertura de Caixa | Debito + roll + CaseOpening + inventario |
| Upgrade | Debito + lock items + roll + resultado + inventario |
| Criar Batalha | Debito + BattlePlayer + Battle status |
| Entrar em Batalha | Debito + BattlePlayer + (se full: execute) |
| Executar Batalha | Rounds + settlement + distribuicao + inventario |
| Vender Item | Status SOLD + credito |
| Aceitar Swap | Troca de ownership dos itens de ambos os lados |
| Comprar Tickets | Debito + criacao de tickets + progresso de evento |
| Sortear Raffle | Roll + vencedores + distribuicao de premios |
| Confirmar Deposito | Credito + bonus + tickets + status CONFIRMED |
| Double-Entry | DEBIT transaction + CREDIT transaction + cache |
Exemplo real: Abertura de Caixa
open-case.use-case.ts:
withUserBalanceLock(userId) { ← Lock: 1 operacao por vez
prisma.$transaction() { ← Transacao: tudo ou nada
balance = getUserBalance(userId) ← Source of Truth: SUM real
if (balance < price) throw Error ← Rejeita se insuficiente
debit(userId, price) ← DEBIT transaction criada
roll = calculateRoll(seeds) ← Provably fair
item = findByHashRange(roll) ← Item determinado
createCaseOpening(roll, item, seeds) ← Registro auditavel
createInventoryItem(userId, item) ← Item no inventario
} ← COMMIT atomico
} ← Lock releasedSe createInventoryItem falhar, o debit e revertido. O usuario nao perde dinheiro sem receber o item.
Camada 3: Status do Inventario
Cada item no inventario tem um status controlado por enum PostgreSQL. Operacoes verificam o status antes de agir e alteram o status atomicamente.
7 status possiveis
AVAILABLE → Pode ser vendido, sacado, usado
LOCKED → Em processo de saque (Waxpeer)
SOLD → Vendido por saldo (estado final)
WITHDRAWN → Sacado com sucesso (estado final)
USED_IN_UPGRADE → Consumido em upgrade (estado final)
USED_IN_BATTLE → Consumido em batalha (estado final)
IN_SWAP → Reservado para troca pendenteComo previne uso simultaneo
Cenario: Usuario tenta vender e sacar o mesmo item ao mesmo tempo.
Thread 1 (Vender):
Lock user:balance:123 → ADQUIRIU
$transaction:
findById(item) → status = AVAILABLE ✓
updateStatus(SOLD)
credit(valueCents)
COMMIT
Release lock
Thread 2 (Sacar):
Lock withdrawal:item:456 → ADQUIRIU
findById(item) → status = SOLD ✗
throw BadRequestException('Item is not available')Mesmo sem lock compartilhado entre venda e saque, o status do item no banco garante que apenas uma operacao procede. O lock serializa operacoes do mesmo tipo; o status previne conflitos entre tipos diferentes.
Verificacoes implementadas
| Operacao | Verifica | Arquivo |
|---|---|---|
| Vender | status !== AVAILABLE → rejeita | sell-inventory-item.use-case.ts:34 |
| Sacar | status !== AVAILABLE → rejeita | request-withdraw-item.use-case.ts:125 |
| Upgrade | status !== AVAILABLE → rejeita | perform-upgrade.use-case.ts:69 |
| Swap | status !== AVAILABLE → rejeita | accept-swap.use-case.ts:71 |
| Vender Multiplos | status !== AVAILABLE → filtra | sell-multiple-items.use-case.ts:38 |
Camada 4: Source of Truth (SUM de Transacoes)
O saldo do usuario nunca e lido do cache (user.balanceCents). Em operacoes financeiras, o saldo real e sempre calculado por:
SELECT type, SUM(amount_cents)
FROM transactions
WHERE user_id = $1 AND status = 'COMPLETED'
GROUP BY typeSaldo Real = SUM(CREDIT) - SUM(DEBIT)Arquivo: src/infrastructure/database/repositories/transaction.repository.ts:78
Por que isso previne TOCTOU
TOCTOU (Time-of-Check-Time-of-Use) e quando o valor verificado muda entre a verificacao e o uso:
SEM protecao (vulneravel):
Thread 1: LE cache → saldo = R$ 100 (TIME OF CHECK)
Thread 2: LE cache → saldo = R$ 100 (TIME OF CHECK)
Thread 1: DEBITA R$ 100 → saldo = R$ 0 (TIME OF USE)
Thread 2: DEBITA R$ 100 → saldo = -R$ 100 (TIME OF USE) ← BUG!
COM protecao (ProCases):
Thread 1: LOCK(user:123) → ADQUIRIU
Thread 2: LOCK(user:123) → AGUARDANDO...
Thread 1: $transaction {
SUM(transactions) = R$ 100 (SOURCE OF TRUTH, dentro da TX)
INSERT DEBIT R$ 100 (atomico com a verificacao)
} COMMIT
Thread 2: LOCK(user:123) → ADQUIRIU
Thread 2: $transaction {
SUM(transactions) = R$ 0 (reflete o debito do Thread 1)
R$ 0 < R$ 100 → REJEITA (saldo insuficiente)
} ROLLBACKTripla protecao contra TOCTOU:
- Redlock: Serializa threads no mesmo usuario
- $transaction: Isolamento READ COMMITTED no PostgreSQL
- SUM real: Calcula do zero a cada operacao (nao confia em cache)
Onde o cache e usado (e onde NAO e)
| Contexto | Usa | Fonte |
|---|---|---|
| Operacoes financeiras (debito/credito) | Source of Truth | SUM(transactions) |
| WebSocket balance update | Source of Truth | getUserBalance() → bigint |
| Exibicao no frontend | Source of Truth via WS | parseInt(balanceCents) |
| Leaderboards / Rankings | Cache | user.balanceCents |
| Verificacao de saldo rapida (nao financeira) | Cache | user.balanceCents |
Camada 5: Webhooks Idempotentes
O webhook de deposito (HorsePay) pode ser chamado multiplas vezes para o mesmo pagamento. O sistema garante que o saldo e creditado exatamente uma vez.
Fluxo real
Webhook 1 chega:
findDeposit(client_reference_id) → status = PENDING
confirmDeposit():
$transaction:
credit(userId, amount + bonus)
updateStatus(CONFIRMED)
COMMIT
→ Saldo creditado ✓
Webhook 2 chega (duplicata):
findDeposit(client_reference_id) → status = CONFIRMED
return { success: true, message: 'Deposit already processed' }
→ Retorna 200 OK sem processar ✓Arquivo: src/application/services/deposit.service.ts:214-217
Dupla protecao
- Status check na aplicacao:
if (deposit.status !== 'PENDING') return - Unique constraint no banco:
Deposit.externalIde unico. Se HorsePay enviar o mesmoexternalIdduas vezes, o banco rejeita na criacao.
Camada 6: Maquinas de Estado (Enums)
Cada entidade critica tem um enum de status no PostgreSQL que controla transicoes validas. O banco recusa valores fora do enum.
Deposito
PENDING → CONFIRMED (unica transicao valida para creditar)
PENDING → FAILED
PENDING → EXPIREDUma vez CONFIRMED, nao ha transicao de volta para PENDING. Reprocessamento e impossivel.
Saque (WaxpeerWithdrawal)
PENDING → WAITING_SELLER → TRADE_SENT → COMPLETED
PENDING → FAILED
WAITING_SELLER → CANCELLED
TRADE_SENT → CANCELLEDCada transicao e verificada antes de ser aplicada:
if (currentStatus === 'COMPLETED') return; // Ja concluido, ignora
if (currentStatus === 'FAILED') return; // Ja falhou, ignoraSwap
PENDING → ACCEPTED (troca itens)
PENDING → DECLINED
PENDING → CANCELLED
PENDING → EXPIREDDouble-accept prevencao:
- Verifica status FORA do lock (fail fast, sem custo)
- Re-verifica status DENTRO do lock (freshSwap - garantia real)
Arquivo: src/application/use-cases/swap/accept-swap.use-case.ts:32,56
Inventario
AVAILABLE → LOCKED / SOLD / USED_IN_UPGRADE / USED_IN_BATTLE / IN_SWAP
LOCKED → AVAILABLE (se saque falhar) / WITHDRAWN (se saque OK)
IN_SWAP → AVAILABLE (se swap cancelar)Transicoes invalidas (ex: SOLD → AVAILABLE) nao existem no codigo e seriam rejeitadas pela logica de verificacao.
Camada 7: Unique Constraints (Banco de Dados)
A ultima linha de defesa e o proprio PostgreSQL. Mesmo que todas as outras camadas falhassem, o banco recusa dados duplicados.
17 constraints que protegem dados criticos
| Tabela | Campo(s) | Previne |
|---|---|---|
| User | steamId | Conta duplicada |
| User | referralCode | Codigo de indicacao duplicado |
| Deposit | externalId | Deposito processado 2x |
| Transaction | relatedTransactionId | Double-entry orfao |
| CaseItem | [caseId, itemId] | Mesmo item 2x na caixa |
| BattlePlayer | [battleId, userId] | Jogador entrando 2x na batalha |
| Battle | joinCode | Codigo de batalha duplicado |
| Item | marketHashName | Item duplicado no catalogo |
| WaxpeerWithdrawal | waxpeerId | Tracking duplicado |
| WaxpeerWithdrawal | projectId | Projeto duplicado |
| RaffleTicket | [raffleId, ticketNumber] | Numero de ticket duplicado |
| EventLevel | [eventId, level] | Nivel duplicado |
| EventProgress | [eventId, userId] | Progresso duplicado |
| EventReward | [eventLevelId, userId] | Recompensa recebida 2x |
| CaseCategory | name | Categoria duplicada |
| CaseCategory | slug | URL duplicada |
| Case | slug | URL duplicada |
Exemplo: BattlePlayer constraint
Se por algum bug dois requests conseguissem passar o lock e a verificacao de alreadyJoined, o PostgreSQL rejeita:
INSERT INTO battle_players (battle_id, user_id, team) VALUES (1, 123, 1);
→ OK
INSERT INTO battle_players (battle_id, user_id, team) VALUES (1, 123, 2);
→ ERROR: Unique constraint failed on (battle_id, user_id)Cenarios de Falha e Protecoes
Cenario 1: Dois cliques em "Abrir Caixa"
Clique 1: LOCK user:balance:123 → ADQUIRIU
Clique 2: LOCK user:balance:123 → AGUARDANDO (retry)
Clique 1:
$transaction:
SUM(transactions) = R$ 50,00
R$ 50,00 >= R$ 15,90 → OK
INSERT DEBIT R$ 15,90
INSERT CaseOpening
INSERT UserInventory
COMMIT → Saldo = R$ 34,10
RELEASE lock
Clique 2: LOCK user:balance:123 → ADQUIRIU
$transaction:
SUM(transactions) = R$ 34,10
R$ 34,10 >= R$ 15,90 → OK
INSERT DEBIT R$ 15,90
INSERT CaseOpening
INSERT UserInventory
COMMIT → Saldo = R$ 18,20
Resultado: 2 aberturas, 2 debitos corretos, saldo consistente ✓Cenario 2: Vender e sacar mesmo item ao mesmo tempo
Thread Venda: LOCK user:balance:123 → ADQUIRIU
Thread Saque: LOCK withdrawal:item:456 → ADQUIRIU (lock diferente, ambos passam)
Thread Venda:
$transaction:
findById(456) → status = AVAILABLE ✓
updateStatus(SOLD)
credit(R$ 25,00)
COMMIT
Thread Saque:
findById(456) → status = SOLD ✗
throw BadRequestException('Item is not available')
Resultado: Item vendido, saque rejeitado, saldo consistente ✓Cenario 3: Servidor crashou durante transacao
withUserBalanceLock(userId) {
$transaction {
debit(R$ 15,90) ← Executou
createCaseOpening() ← Executou
createInventory() ← CRASH AQUI
}
← $transaction: ROLLBACK automatico (PostgreSQL)
← debit revertido, saldo intacto
}
← Lock expira apos TTL (10s), proximo request pode prosseguir
Resultado: Nenhum dado corrompido, saldo intacto ✓Cenario 4: Redis caiu (locks indisponiveis)
withUserBalanceLock(userId) {
← Redlock.acquire() FALHA (Redis offline)
← Retry 1/10... Retry 10/10... TIMEOUT
← throw Error('Could not acquire lock')
}
Resultado: Operacao rejeitada, nenhum dado alterado ✓
(Seguro por design: sem lock = sem operacao)Cenario 5: HorsePay envia webhook 3x para mesmo deposito
Webhook 1:
findDeposit(ref:ABC) → status = PENDING
confirmDeposit():
credit(R$ 50,00 + bonus de cupom)
updateStatus(CONFIRMED)
→ Creditado ✓
Webhook 2:
findDeposit(ref:ABC) → status = CONFIRMED
return { success: true, message: 'Already processed' }
→ Ignorado ✓
Webhook 3:
findDeposit(ref:ABC) → status = CONFIRMED
return { success: true, message: 'Already processed' }
→ Ignorado ✓
Resultado: Saldo creditado exatamente 1x ✓Cenario 6: Dois jogadores tentam entrar no ultimo slot da batalha
User A: LOCK user:balance:A → ADQUIRIU
User B: LOCK user:balance:B → ADQUIRIU (userId diferente, nao bloqueia)
User A:
findBattle() → players = 5, max = 6
$transaction:
debit(entry fee)
addPlayer(A) → players = 6
COMMIT
User B:
findBattle() → players = 6, max = 6
throw BadRequestException('Battle is full')
← Saldo de B NAO foi debitado
Resultado: A entrou, B rejeitado sem perder saldo ✓Nota: A verificacao players.length >= maxPlayers acontece antes do debito. Mesmo sem lock entre usuarios, nenhum jogador perde dinheiro.
Cenario 7: Aceitar swap que ja foi aceito por outro usuario
User X: lock:swap:789 → ADQUIRIU
lock:user:balance:A → ADQUIRIU
lock:user:balance:B → ADQUIRIU
User X:
freshSwap = findById(789) → status = PENDING ✓
$transaction:
Troca itens de A para B e vice-versa
updateStatus(ACCEPTED)
COMMIT
RELEASE locks
User Y: lock:swap:789 → ADQUIRIU
freshSwap = findById(789) → status = ACCEPTED ✗
throw BadRequestException('Swap is no longer available')
Resultado: Troca executada 1x, segundo aceite rejeitado ✓Por que e o Melhor Sistema
Comparacao com plataformas tipicas
| Aspecto | Plataforma Tipica | ProCases |
|---|---|---|
| Saldo | Coluna no banco (cache) | SUM de transacoes (source of truth) |
| Debito | UPDATE user SET balance = balance - X | INSERT DEBIT + INSERT CREDIT (double-entry) |
| Concorrencia | Optimistic locking ou nenhum | Distributed locks (Redlock) + DB transactions |
| Items | Flag boolean (available: true/false) | Enum com 7 estados + transicoes controladas |
| Webhooks | Processado e torcemos para nao duplicar | Idempotente por design (status check + unique constraint) |
| Auditoria | Logs em arquivo | Hash chaining SHA-256 (blockchain-like, verificavel) |
| IDs | Auto-increment (previsivel) ou UUID (lento) | Snowflake 64-bit (rapido, ordenavel, distribuido) |
| Valores | Float ou Decimal | BIGINT centavos (zero imprecisao) |
| Resultados | Random server-side (confia no servidor) | Provably fair HMAC-SHA256 (verificavel por qualquer pessoa) |
| Busca | LIKE '%termo%' (full scan) | pg_trgm + GIN index (sub-milissegundo) |
| Indexes | Basicos (PK + FK) | 49 indexes especializados (16 partial, 28 composite, 1 GIN) |
O que torna o sistema interligado
Nenhum componente opera isoladamente. Cada operacao financeira passa por uma cadeia inquebravel:
Request HTTP
→ AuthGuard (valida sessao, IP, User-Agent)
→ Controller (valida DTOs com class-validator)
→ Use Case (logica de negocio)
→ LockService.withUserBalanceLock() [Redis]
→ PrismaService.$transaction() [PostgreSQL]
→ TransactionService.getUserBalance() [Source of Truth]
→ TransactionService.debit/credit() [Double-Entry]
→ AuditService.log() [Hash Chaining]
→ Commit atomico
→ Release lock
→ WebSocketGateway.emitBalanceUpdate() [Tempo real]
→ Response HTTPSe qualquer elo da cadeia falhar:
- AuthGuard falha → Request rejeitado (401)
- Validacao falha → Request rejeitado (400)
- Lock falha → Operacao nao inicia (nenhum dado alterado)
- Transaction falha → Rollback (nenhum dado alterado)
- Balance insuficiente → Exception (nenhum dado alterado)
- WebSocket falha → Saldo correto no banco (frontend atualiza no proximo GET)
Nao existe estado intermediario. O sistema esta correto ou nao executou.
Verificabilidade
| O que | Como verificar |
|---|---|
| Saldo de qualquer usuario | SELECT SUM(CASE WHEN type='CREDIT' THEN amount ELSE -amount END) FROM transactions WHERE user_id = X |
| Integridade do sistema | SELECT SUM(CASE WHEN type='CREDIT' THEN amount ELSE -amount END) FROM transactions → deve ser 0 |
| Auditoria | GET /api/audit/verify-chain → verifica cada hash contra o anterior |
| Resultado de jogo | Recalcular HMAC-SHA256(serverSeed, clientSeed:nonce) e comparar com roll salvo |
| Items de uma caixa | Verificar que SUM(hashRangeEnd - hashRangeStart + 1) = 10.000.000 |
Qualquer pessoa pode verificar qualquer aspecto do sistema de forma independente.
Arquivos Fonte
Distributed Locks
src/infrastructure/locks/lock.service.ts- Implementacao Redlock
Transacoes e Saldo
src/application/services/transaction.service.ts- Double-entry + getUserBalancesrc/infrastructure/database/repositories/transaction.repository.ts- SUM query
Use Cases Protegidos
src/application/use-cases/case-opening/open-case.use-case.ts- Abertura de caixasrc/application/use-cases/upgrade/perform-upgrade.use-case.ts- Upgradesrc/application/use-cases/battle/join-battle.use-case.ts- Entrar em batalhasrc/application/use-cases/inventory/sell-inventory-item.use-case.ts- Vender itemsrc/application/use-cases/inventory/request-withdraw-item.use-case.ts- Sacar itemsrc/application/use-cases/swap/accept-swap.use-case.ts- Aceitar trocasrc/application/use-cases/raffle/buy-raffle-tickets.use-case.ts- Comprar tickets
Webhooks e Depositos
src/application/services/deposit.service.ts- Webhook idempotentesrc/infrastructure/payment/horsepay.provider.ts- Integracao HorsePay
Auditoria
src/infrastructure/audit/audit.service.ts- Hash chaining SHA-256
