Por que reenviar uma requisição de criação com upsert faz o status voltar atrás?

Upsert que sobrescreve a linha inteira não é idempotente: o reenvio desfaz o que já avançou. Como usar ON CONFLICT DO NOTHING e provar que nada regrediu.

Pedro Henrique Quadroatualizado em 4 min de leitura

Porque um upsert que sobrescreve a linha inteira, chamado uma segunda vez, escreve de novo os valores do momento da criação por cima de tudo o que mudou depois. Se o cliente reenvia a criação sozinho, por reconexão ou fila offline, o status avançado volta ao inicial. A correção é separar criar de atualizar: a criação deve inserir e, se a linha já existir, deixá-la como está.

O que é um upsert, e por que parece seguro

Upsert é "atualiza ou insere": se a linha não existe, cria, e se existe, atualiza. No PostgreSQL ele se escreve INSERT ... ON CONFLICT, e a documentação dá duas alternativas para o caso de conflito. DO NOTHING simplesmente evita inserir. DO UPDATE atualiza a linha que conflita com a proposta. A documentação ainda garante que o DO UPDATE tem resultado atômico, inserção ou atualização, mesmo sob alta concorrência.

O problema é a palavra "idempotente", que costuma ser atribuída ao upsert sem conferir. Idempotente quer dizer que repetir a chamada dá o mesmo resultado que fazê-la uma vez. Isso só vale se a segunda chamada não desfizer nada. Um DO UPDATE que grava todos os campos do payload desfaz.

O que aconteceu comigo

Num app meu que transcreve reuniões, o endereço de "criar reunião" gravava com upsert, sobrescrevendo a linha. O cliente do app reenvia essa criação por desenho: quando o service worker reconecta, quando a fila offline é descarregada, quando a rede oscila. Na segunda chamada, a reunião que já estava concluída voltou para "em andamento", e o título real, promovido depois por outra ação, foi trocado pelo texto padrão "Reuniao no Google Meet". Era a terceira porta para um defeito que eu já tinha fechado duas vezes: a reunião "ao vivo para sempre".

Esse tipo de erro não aparece no caminho feliz nem em teste unitário, porque a primeira chamada faz tudo certo. Ele só existe na segunda, e a segunda é exatamente o que o cliente faz sozinho. Quem lê o código vê "upsert" e segue em frente.

A segunda ocorrência, sem reenvio nenhum

A mesma raiz apareceu numa importação de documentos em lote, onde não houve reenvio. O ON CONFLICT DO UPDATE escrevia só o que vinha no documento novo, e o gerador dos SQL não preservava um campo que o documento novo não trazia. Num dos lotes havia 947 linhas nesse campo, 78 kB, que sobreviveram só porque corrigi o gerador antes de aplicar. Sem isso, uma funcionalidade daquele mês teria morrido em silêncio, com o número certo na tela e nenhum erro.

O que fazer

  1. Separe as ações. Criar e atualizar são operações diferentes, cada uma com seu endereço ou sua função.
  2. Na criação, não atualize. Use ON CONFLICT DO NOTHING e deixe a linha existente intacta:
insert into reunioes (id, titulo, status)
values ($1, $2, 'em_andamento')
on conflict (id) do nothing;

No supabase-js, a opção ignoreDuplicates do upsert serve a isso. A página de referência cita a opção sem detalhar os valores; no meu uso, com ignoreDuplicates: true ela se comporta como DO NOTHING.

  1. Quando o upsert precisa mesmo atualizar um documento, preserve o que não veio com coalesce(excluded.campo, tabela.campo), em vez de set dados = <novo> cru.
  2. Arquive antes de trocar. Antes de aplicar a importação, copiei as linhas existentes para uma tabela de histórico. Sem essa cópia, "o número certo na tela" é a única prova que sobra, e ela não mostra o que sumiu.
  3. Ganhe de brinde o fim de uma corrida. Na minha versão antiga, a criação fazia select e depois insert, e dois envios paralelos estouravam a chave primária e viravam erro 500. Com ON CONFLICT DO NOTHING, o segundo simplesmente não faz nada.

Como provar que ficou certo

O teste é o roteiro completo, não a primeira chamada: criar, mudar o status, concluir, reenviar a criação e conferir que nada regrediu. Leia os campos direto do banco, não a resposta 200 do endereço, porque o 200 vem igual nos dois casos.

Reenvio é comportamento normal de qualquer cliente que tenha rede ruim ou fila offline. Montar integrações que aguentam isso é parte do que faço em automação e integrações.

Fontes

  1. PostgreSQL INSERT: ON CONFLICT DO NOTHING evita inserir e DO UPDATE atualiza a linha em conflito, com resultado atômico
  2. Supabase JS: upsert, onConflict e ignoreDuplicates
Pedro Henrique QuadroEngenheiro de software e IA. Constrói aplicativos, sistemas, painéis e agentes de IA em produção, e tem código aceito no Supabase, no Kestra e no QuestDB.

Continue lendo