Skip to content

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.

CamadaMecanismoO que previneOnde atua
1Distributed Lock (Redlock)Operacoes simultaneas no mesmo recursoRedis
2Transacao Atomica (Prisma)Atualizacoes parciais no bancoPostgreSQL
3Status do Inventario (Enum)Uso simultaneo do mesmo itemPostgreSQL
4Source of Truth (SUM de TX)Saldo desatualizado (TOCTOU)PostgreSQL
5Webhook IdempotenteDeposito creditado 2xAplicacao
6Maquinas de Estado (Enums)Transicoes de estado invalidasPostgreSQL
7Unique ConstraintsDuplicacao de dados criticosPostgreSQL

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

LockChave RedisTTLProtege
User Balancelock:user:balance:{userId}10sQualquer operacao de saldo
Transactionlock:transaction:create5sCriacao de double-entry
Withdrawallock:withdrawal:item:{itemId}30sSaque do mesmo item
Swaplock:swap:{swapId}5sAceitar 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 CaseArquivoO que protege
Abertura de Caixaopen-case.use-case.ts:60Debito do preco + criacao do item
Upgradeperform-upgrade.use-case.ts:51Debito + roll + resultado
Criar Batalhacreate-battle.use-case.ts:50Debito da entry fee
Entrar em Batalhajoin-battle.use-case.ts:57Debito da entry fee
Vender Itemsell-inventory-item.use-case.ts:38Credito do valor do item
Vender Multiplossell-multiple-items.use-case.ts:45Credito total
Vender Todossell-all-items.use-case.ts:28Credito total
Criar Swapcreate-swap.use-case.ts:38Lock dos itens oferecidos
Site Swapperform-site-swap.use-case.ts:79Debito/credito do swap
Comprar Ticketsbuy-raffle-tickets.use-case.ts:41Debito do custo
Admin Ajusteupdate-user-balance.use-case.ts:24Ajuste 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 normalmente

Resultado: 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

ParametroValorSignificado
maxWait10 segundosTempo maximo esperando conexao do pool
timeout30 segundosTempo maximo de execucao da transacao
IsolationREAD COMMITTEDPadrao PostgreSQL, previne dirty reads

Arquivo: src/infrastructure/database/prisma.service.ts

15 use cases usam transacoes atomicas

OperacaoO que e atomico
Abertura de CaixaDebito + roll + CaseOpening + inventario
UpgradeDebito + lock items + roll + resultado + inventario
Criar BatalhaDebito + BattlePlayer + Battle status
Entrar em BatalhaDebito + BattlePlayer + (se full: execute)
Executar BatalhaRounds + settlement + distribuicao + inventario
Vender ItemStatus SOLD + credito
Aceitar SwapTroca de ownership dos itens de ambos os lados
Comprar TicketsDebito + criacao de tickets + progresso de evento
Sortear RaffleRoll + vencedores + distribuicao de premios
Confirmar DepositoCredito + bonus + tickets + status CONFIRMED
Double-EntryDEBIT 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 released

Se 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 pendente

Como 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

OperacaoVerificaArquivo
Venderstatus !== AVAILABLE → rejeitasell-inventory-item.use-case.ts:34
Sacarstatus !== AVAILABLE → rejeitarequest-withdraw-item.use-case.ts:125
Upgradestatus !== AVAILABLE → rejeitaperform-upgrade.use-case.ts:69
Swapstatus !== AVAILABLE → rejeitaaccept-swap.use-case.ts:71
Vender Multiplosstatus !== AVAILABLE → filtrasell-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:

sql
SELECT type, SUM(amount_cents)
FROM transactions
WHERE user_id = $1 AND status = 'COMPLETED'
GROUP BY type
Saldo 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)
  } ROLLBACK

Tripla protecao contra TOCTOU:

  1. Redlock: Serializa threads no mesmo usuario
  2. $transaction: Isolamento READ COMMITTED no PostgreSQL
  3. SUM real: Calcula do zero a cada operacao (nao confia em cache)

Onde o cache e usado (e onde NAO e)

ContextoUsaFonte
Operacoes financeiras (debito/credito)Source of TruthSUM(transactions)
WebSocket balance updateSource of TruthgetUserBalance()bigint
Exibicao no frontendSource of Truth via WSparseInt(balanceCents)
Leaderboards / RankingsCacheuser.balanceCents
Verificacao de saldo rapida (nao financeira)Cacheuser.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

  1. Status check na aplicacao: if (deposit.status !== 'PENDING') return
  2. Unique constraint no banco: Deposit.externalId e unico. Se HorsePay enviar o mesmo externalId duas 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 → EXPIRED

Uma 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 → CANCELLED

Cada transicao e verificada antes de ser aplicada:

if (currentStatus === 'COMPLETED') return;  // Ja concluido, ignora
if (currentStatus === 'FAILED') return;     // Ja falhou, ignora

Swap

PENDING → ACCEPTED (troca itens)
PENDING → DECLINED
PENDING → CANCELLED
PENDING → EXPIRED

Double-accept prevencao:

  1. Verifica status FORA do lock (fail fast, sem custo)
  2. 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

TabelaCampo(s)Previne
UsersteamIdConta duplicada
UserreferralCodeCodigo de indicacao duplicado
DepositexternalIdDeposito processado 2x
TransactionrelatedTransactionIdDouble-entry orfao
CaseItem[caseId, itemId]Mesmo item 2x na caixa
BattlePlayer[battleId, userId]Jogador entrando 2x na batalha
BattlejoinCodeCodigo de batalha duplicado
ItemmarketHashNameItem duplicado no catalogo
WaxpeerWithdrawalwaxpeerIdTracking duplicado
WaxpeerWithdrawalprojectIdProjeto duplicado
RaffleTicket[raffleId, ticketNumber]Numero de ticket duplicado
EventLevel[eventId, level]Nivel duplicado
EventProgress[eventId, userId]Progresso duplicado
EventReward[eventLevelId, userId]Recompensa recebida 2x
CaseCategorynameCategoria duplicada
CaseCategoryslugURL duplicada
CaseslugURL 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

AspectoPlataforma TipicaProCases
SaldoColuna no banco (cache)SUM de transacoes (source of truth)
DebitoUPDATE user SET balance = balance - XINSERT DEBIT + INSERT CREDIT (double-entry)
ConcorrenciaOptimistic locking ou nenhumDistributed locks (Redlock) + DB transactions
ItemsFlag boolean (available: true/false)Enum com 7 estados + transicoes controladas
WebhooksProcessado e torcemos para nao duplicarIdempotente por design (status check + unique constraint)
AuditoriaLogs em arquivoHash chaining SHA-256 (blockchain-like, verificavel)
IDsAuto-increment (previsivel) ou UUID (lento)Snowflake 64-bit (rapido, ordenavel, distribuido)
ValoresFloat ou DecimalBIGINT centavos (zero imprecisao)
ResultadosRandom server-side (confia no servidor)Provably fair HMAC-SHA256 (verificavel por qualquer pessoa)
BuscaLIKE '%termo%' (full scan)pg_trgm + GIN index (sub-milissegundo)
IndexesBasicos (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 HTTP

Se 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 queComo verificar
Saldo de qualquer usuarioSELECT SUM(CASE WHEN type='CREDIT' THEN amount ELSE -amount END) FROM transactions WHERE user_id = X
Integridade do sistemaSELECT SUM(CASE WHEN type='CREDIT' THEN amount ELSE -amount END) FROM transactions → deve ser 0
AuditoriaGET /api/audit/verify-chain → verifica cada hash contra o anterior
Resultado de jogoRecalcular HMAC-SHA256(serverSeed, clientSeed:nonce) e comparar com roll salvo
Items de uma caixaVerificar 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 + getUserBalance
  • src/infrastructure/database/repositories/transaction.repository.ts - SUM query

Use Cases Protegidos

  • src/application/use-cases/case-opening/open-case.use-case.ts - Abertura de caixa
  • src/application/use-cases/upgrade/perform-upgrade.use-case.ts - Upgrade
  • src/application/use-cases/battle/join-battle.use-case.ts - Entrar em batalha
  • src/application/use-cases/inventory/sell-inventory-item.use-case.ts - Vender item
  • src/application/use-cases/inventory/request-withdraw-item.use-case.ts - Sacar item
  • src/application/use-cases/swap/accept-swap.use-case.ts - Aceitar troca
  • src/application/use-cases/raffle/buy-raffle-tickets.use-case.ts - Comprar tickets

Webhooks e Depositos

  • src/application/services/deposit.service.ts - Webhook idempotente
  • src/infrastructure/payment/horsepay.provider.ts - Integracao HorsePay

Auditoria

  • src/infrastructure/audit/audit.service.ts - Hash chaining SHA-256

Documentação Técnica - ProCases