Pular para conteúdo

0031 — Mescla concorrente: duas defesas contra deadlock (40P01)

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10 · Decidido em: 2026-07-27

Contexto

Desde o envio em lote (0032), um .zip traz N planilhas e cada uma vira um processamento próprio no executor processamento (4 threads): dois arquivos do mesmo lote mesclam em paralelo no banco. A mescla é o UPSERT diff-aware + DELETE seletivo (0006, 0007), uma transação por arquivo, em bi_faturamento e bi_movimento.

Três fatos do PostgreSQL tornam isso um deadlock (40P01) latente — o Postgres derruba uma das transações e o processamento vai a ERRO:

  • INSERT … ON CONFLICT … DO UPDATE trava a linha conflitante mesmo quando o WHERE … IS DISTINCT FROM não atualiza nada — linha inalterada também entra na disputa.
  • Sem ordem definida, cada transação adquire os row locks na ordem em que o SELECT da TEMP TABLE devolve as linhas; duas transações que tocam as mesmas chaves em ordens diferentes se travam mutuamente.
  • O DELETE seletivo, na mesma transação, trava linhas na ordem do scan, que nenhum ORDER BY controla.

O bi-comercial-xls tinha o mesmo risco e o corrigiu com o ORDER BY. Aqui o risco é maior, por causa do DELETE seletivo.

Decisão

Duas defesas, ambas obrigatórias (commit ff1f811, release 2.1.3):

  1. ORDER BY pela chave natural no SELECT do INSERT … SELECT … ON CONFLICT do BulkUpserter: a ordem de aquisição de row lock fica determinística entre transações concorrentes (convenção da frota bi-*).
  2. Advisory lock de mescla: ProcessamentoTransactionalHelper.mesclarDadosDoPeriodo abre, como primeira instrução, com BulkUpserter.adquirirLockMescla → SELECT pg_advisory_xact_lock(<chave>). Serializa a fase de banco inteira e cobre o que o ORDER BY não cobre: UPSERT × DELETE seletivo e DELETE × DELETE.

Detalhes que fazem parte da decisão:

  • Uma chave só, global (ADVISORY_LOCK_MESCLA, o ASCII de "bitransp"), para as duas tabelas: faturamento e movimento são sempre mesclados juntos, na mesma transação.
  • pg_advisory_xact_lock, liberado sozinho no COMMIT/ROLLBACK da transação da mescla.
  • adquirirLockMescla não tem @Transactional, de propósito: exige a transação do chamador (e falha alto sem ela). Numa transação própria, o lock seria liberado na saída do método — inútil, em silêncio.
  • Espera acima de 50 ms vai para o log do processamento ("Aguardou N ms pelo lock de mescla").

Consequências

  • Só a fase de banco enfileira; o parse do Excel (a fase cara) segue paralelo nas 4 threads.
  • O custo é throughput de banco serializado: mesclas não se sobrepõem, nem entre lotes diferentes (a chave é global).
  • Remover qualquer uma das duas reintroduz o deadlock com 2+ arquivos em paralelo. O ORDER BY é guardado pelo BulkUpserterSqlTest (pré-condição estrutural no SQL gerado); a invariante está no javadoc do BulkUpserter e no gotcha do CLAUDE.md.

Alternativas consideradas

  • Só o ORDER BY (a correção do bi-comercial-xls): não cobre o DELETE seletivo, cuja ordem de lock segue o scan. Insuficiente aqui.
  • Serializar o executor inteiro (uma thread): também elimina o deadlock, mas serializa o parse, que é a parte cara. O advisory lock serializa só o banco.