Blog / Engenharia / No.007

Uma lista de exclusão que funciona entre contas

Um opt out global é uma frase em uma política e uma semana de engenharia. Chave por domínio, checagem em toda etapa, um compare-and-set na entrega, e linhas que ninguém apaga.

Mohamad · Knockwire9 de junho de 20269 min de leituraEngenharia

Este texto é uma tradução do original em inglês.

Uma lista de exclusão é fácil de prometer. "Peça para pararmos e nunca mais contatamos você" é uma frase em uma página de política e mais ou menos uma semana de engenharia, a maior parte dela em lugares que você não adivinharia. O schema é uma tarde. A dificuldade é que uma lista de exclusão precisa valer entre contas, precisa alcançar para trás o trabalho já em andamento, e precisa conseguir provar depois o que ela impediu.

01O schema, e o índice que faz o trabalho

A exclusão é chaveada pelo domínio registrável. Não pelo hostname, não pelo endereço de e-mail individual. Quem pede para pararmos em acme.com não concordou em ouvir de nós em www.acme.com nem em carreiras.acme.com. Normalizamos pela public suffix list, deixamos em minúsculas, tiramos um www inicial e convertemos nomes internacionalizados para a forma punycode antes de guardar, porque duas grafias de uma empresa na tabela é funcionalmente igual a nenhuma entrada.

  • domain: o domínio registrável normalizado, e a única coluna que algum dia é comparada.
  • tenant_id: a conta a que a exclusão pertence, com um valor sentinela para quando ela vale na plataforma inteira.
  • reason: texto livre, obrigatório, sem valor padrão. Se ninguém consegue dizer por quê, não entra.
  • source: como chegou. Uma resposta, um pedido por formulário, uma ação de operador, uma importação em massa, um pedido jurídico. Esses casos se comportam de forma diferente mais adiante, e um booleano sozinho joga tudo isso fora.
  • evidence: um link para a mensagem ou para o ticket que causou a exclusão.
  • created_by e created_at: quem, e quando.

O índice único sobre cliente e domínio é o que torna seguro re-adicionar. As escritas passam por um insert que atualiza motivo, origem e timestamp em caso de chave duplicada em vez de falhar, então um operador pode colar a mesma lista de quarenta domínios duas vezes e receber quarenta linhas e nenhum erro. Re-adicionar de forma idempotente importa mais do que parece. A alternativa é que as pessoas leiam antes de escrever, e ler para depois escrever é exatamente a corrida que o índice existe para remover.

ArmadilhaÉ tentador modelar uma exclusão válida na plataforma inteira como um tenant_id NULL. No MySQL um índice único trata cada NULL como distinto de todos os outros NULLs, então esse índice vai aceitar alegremente o mesmo domínio global cem vezes. Use um id de cliente sentinela, ou uma coluna de escopo separada que nunca seja nula. Um índice só protege o que ele consegue comparar.

02Cheque em toda etapa, não só na entrada

O pipeline é uma sequência com tempo real entre os passos. Uma empresa lida chega de uma fonte, passa pelos filtros e vira qualificada, é pesquisada, ganha um rascunho, espera uma pessoa, e só então um envio é realizado. Dias se passam entre o primeiro passo e o último. Uma empresa pode virar excluída em qualquer ponto dentro dessa janela.

O que torna uma checagem de exclusão na descoberta quase inútil sozinha. Se a entrada é a única checagem, uma empresa que pede para sair na terça ainda tem um rascunho na fila de aprovação desde segunda, e esse rascunho vai sair. A lista estava certa, a entrada estava lá, e a mensagem foi assim mesmo. A falha não está no dado.

  • Na entrada, para que um domínio excluído nunca vire uma empresa lida.
  • Antes da pesquisa, porque pesquisa gasta orçamento e não faz sentido gastar com uma empresa que não pode ser contatada.
  • Antes da redação, pelo mesmo motivo e com um custo maior anexado.
  • Quando a fila de aprovação é renderizada, mostrada como bloqueada em vez de escondida, para que a pessoa veja que algo foi impedido em vez de se perguntar para onde aquilo foi.
  • Imediatamente antes da entrega, como a última instrução executada antes de a requisição sair.

Só a última delas sustenta a correção. As outras quatro poupam trabalho e evitam que uma pessoa gaste atenção aprovando algo que nunca poderá sair. A checagem final precisa ler o banco em vez de qualquer cache por rodada, porque um cache está velho exatamente pelo intervalo em que o bug vive. É uma consulta indexada contra uma tabela de alguns milhares de linhas, e não vale otimizar.

03Dois workers, uma empresa

O caso de concorrência sobrevive à revisão porque é difícil de imaginar. Dois workers pegam a mesma empresa: um lease que expirou sem o primeiro worker notar, ou um negócio chegando de duas fontes de descoberta sob ids diferentes que normalizam para o mesmo domínio. Os dois checam a exclusão. Os dois passam. Os dois escrevem um rascunho. A fila de aprovação agora tem duas mensagens para uma empresa, e recusar uma delas não faz nada com a outra.

Uma transação mais longa não resolve isso, e checar duas vezes também não. Uma constraint única resolve. A linha do rascunho carrega um índice único sobre cliente, domínio e campanha, então o segundo insert perde no banco em vez de perder na lógica de aplicação, e o worker perdedor trata chave duplicada como desfecho comum em vez de erro. O lease carrega um claimed_at e um id de worker, então um job travado fica visível em vez de apenas atrasado.

A entrega usa a mesma ideia pelo outro lado. O passo de envio move o rascunho de aprovado para enviando com o estado atual nomeado na cláusula WHERE, e segue só se aquele update afetou exatamente uma linha. Se um evento de exclusão virou a linha para barrada um segundo antes, o update não casa com nada, o envio não acontece, e nenhum código de aplicação precisou raciocinar sobre a ordem. Esse compare-and-set é o que torna a exclusão livre de corrida em vez de correta na maioria das vezes.

Se uma exclusão só filtra o que vem depois, ela não fez aquilo que prometeu.

04A exclusão precisa alcançar para trás

Marcar um domínio como excluído é um evento, não um filtro. Quando ela chega, cancela pesquisa em fila para aquele domínio, move todo rascunho daquele domínio para barrado com o id da exclusão registrado como motivo, e tira essas linhas da fila de aprovação. O trabalho já feito é o problema inteiro. Um filtro aplicado só a leituras futuras deixa intactas as concluídas, e as concluídas são as únicas perto o bastante de sair.

As linhas barradas então ficam. Não apagamos, e é essa a parte que gera discussão, porque apagar parece a opção respeitosa. Apagar destrói a única evidência de que algo foi impedido. Quando uma empresa escreve perguntando se andamos contatando ela, uma resposta honesta é específica: um rascunho foi escrito no dia nove, foi barrado no dia onze quando o domínio entrou na exclusão, nunca foi entregue, e aqui está o texto dele. Essa resposta precisa que a linha exista.

Então a regra vale nas duas direções: nunca apagamos um registro de recusa. Toda leitura recusada, todo rascunho barrado e toda entrada de exclusão são guardados, e o campo de motivo é obrigatório nos três. Um filtro que você não consegue auditar é só uma lista, e lista é o que todo mundo já diz ter.

05Lista de clientes não é lista de exclusão

O último erro parece arrumação. Um cliente já tem uma lista dos próprios clientes e não quer que eles sejam prospectados, então alguém carrega essa lista na tabela de exclusão. Funciona por uma semana, e depois a informação sumiu.

São fatos diferentes sobre uma empresa. Excluída quer dizer que uma pessoa pediu para pararmos. Isso vence tudo, não expira, e vale qualquer que seja a mensagem. Já é cliente quer dizer não trate isto como candidato novo, o que não é a mesma instrução: o time de contas pode muito bem querer uma conversa de expansão, e se quiser, a tabela de exclusão agora está recusando em nome de alguém que nunca pediu isso. Exclusão de concorrente é uma terceira coisa. Uma retenção pedida por vendas enquanto um negócio está em andamento é uma quarta, e ao contrário das outras ela tem data de fim.

Mantidos separados, a pergunta "por que esta empresa nunca foi contatada" tem uma resposta de uma palavra que uma query produz. Fundidos em uma tabela atrás de um motivo em texto livre, a resposta exige que alguém leia prosa e infira, e em um mês a coluna de motivo está juntando valores como "cliente, não remover". Tabelas separadas, semânticas separadas, e uma checagem compartilhada no ponto da entrega que consulta todas.

Entrega, por ora, quer dizer o formulário de contato público da própria empresa alvo. O envio de e-mail está previsto para o quarto trimestre de 2026 e não está construído, o que silenciosamente simplifica tudo o que está acima: há um canal só para excluir. Quando houver dois, cada checagem descrita aqui roda nos dois, e alcançar para trás é a parte que vai exigir mais cuidado.

A Knockwire lê a internet, descarta as empresas que nunca vão comprar de você e bate na porta das que podem comprar. Rode no seu próprio domínio e leia as suas recusas.

Rodar no meu siteTodos os textos