Skip to content

feat(enterprise-import): conta as linhas descartadas por motivo - #23

Open
daniloleonecarneiro wants to merge 4 commits into
etusdigital:mainfrom
daniloleonecarneiro:bms-7-levantar-por-que-o-script-atual-de-importacao-perde-contatos
Open

feat(enterprise-import): conta as linhas descartadas por motivo#23
daniloleonecarneiro wants to merge 4 commits into
etusdigital:mainfrom
daniloleonecarneiro:bms-7-levantar-por-que-o-script-atual-de-importacao-perde-contatos

Conversation

@daniloleonecarneiro

@daniloleonecarneiro daniloleonecarneiro commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

O progresso de um job de importação guardava done, total e page, e nada sobre o que foi descartado. Cada caminho de perda era um continue mudo ou um conflito engolido pelo ON CONFLICT, então uma importação que perdesse metade das linhas terminava com exatamente a mesma cara de uma que não perdeu nenhuma. Este PR dá número ao que se perde.

A motivação é concreta. Comparando o que os jobs já rodados reportaram com o que existe no banco:

conta reportado no banco diferença
1 33.089 33.089 0
2 375.362 375.351 11

As 11 linhas da conta 2 sumiram sem deixar rastro, e done as contou como importadas — ele soma candidatas, não linhas gravadas. E essa é a única perda que hoje dá para enxergar: os outros três caminhos descartam a linha antes de ela virar candidata, então não aparecem em nenhum dos dois números.

Changes

  • ImportProgressEntry ganha seen (linhas lidas da origem) e discarded (contagem por motivo), com o tipo DiscardReason enumerando os quatro caminhos.
  • BaseImporter conta cada um deles: mapped_null (customize recusou ou FK não resolveu), empty_natural_key, duplicate_in_page e insert_conflict.
  • insert_conflict é medido contando as linhas presentes depois da escrita. Nenhum dos dois caminhos de escrita reporta quantas linhas gravou — orIgnore() em account-scope e ON CONFLICT DO NOTHING em instance-scope —, e é justamente esse buraco que deixa passar a linha que clareia o pré-filtro da chave natural e ainda perde para outra constraint única da tabela.
  • Um passo que leu linhas e não gravou nenhuma deixa de ser marcado skipped: empty e passa a all_discarded. Chamar isso de vazio era parte do que escondia a perda.
  • O bloco discarded é omitido quando não há descarte, para que uma importação limpa continue limpa na tela. Quando há, sai também um logger.warn.

Type of change

  • Bug fix (fix:)
  • New feature (feat:)
  • Refactor (refactor:)
  • Docs only (docs:)
  • Tooling / chore (chore:)
  • Breaking change

Testing

Sete casos novos em base.importer.spec.ts, um por caminho de descarte mais os dois de borda:

  • customize recusando a linha, e FK não resolvida em scope=account — ambos contam mapped_null;
  • chave natural vazia e repetida dentro da página;
  • conflito engolido no insert, que também prende o comportamento de done contar candidatas e não gravadas;
  • importação limpa não emite o bloco discarded;
  • passo que leu e não gravou vira all_discarded, não empty.

O FakeRepo do spec ganhou count, que é o método novo que o importer passou a usar.

  • pnpm type-check passes
  • pnpm lint passes
  • pnpm test passes — src/importers/, 32 testes em 3 suítes
  • Manually verified the change end-to-end

Não rodei uma importação real: exige uma instância Enterprise de origem, que não existe no ambiente local. Os números da tabela acima vêm dos jobs já gravados em enterprise_import_jobs, não de uma execução minha.

Screenshots

Não se aplica.

Notes for reviewers

Relação com o #19 — de leitura, não de código. Este PR sai direto de main e pode ser mergeado sozinho, em qualquer ordem em relação ao #19: aquele PR vive em apps/msgops-api/src/modules/enterprise-import e no frontend, e não toca uma linha de apps/enterprise-import. Mas o que ele estabelece é o que dá sentido a este: a sessão de reconcile trata a unicidade de email como resultado de primeira classe. Um item que colidiria nasce com status conflict e um failureReason legível, e o resumo conta applied, failed, conflict, pending e skipped. É exatamente a disciplina que faltava do lado do worker, onde a mesma classe de colisão é engolida em silêncio. Este PR estende essa disciplina ao importador.

Por que os contatos chegam com email mascarado, e o que isso implica na constraint. O GET /contacts do Enterprise devolve o email mascarado (lucas***@gmail.com) — é a razão de existir do módulo de reconcile. A tabela contacts tem UNIQUE (email, hashed_email, account_id), e o hashed_email vem da origem calculado sobre o email real: o importer escreve por SQL cru e por updateEntity(false), que não disparam o @BeforeInsert que recalcularia o hash. Confirmei no banco — o hashed_email gravado não é o sha256 do email mascarado.

A consequência é a que interessa: dois contatos com o mesmo email real chegam com a mesma máscara e o mesmo hash, colidem na constraint e o segundo é engolido pelo orIgnore, contado como importado. Já dois contatos com emails reais diferentes que mascaram para a mesma string não colidem, porque o hash difere — na conta 2 há uma máscara com 203 linhas e 203 hashes distintos. Ou seja, a colisão é rara mas real, e é uma candidata direta para as 11 linhas. O contador é o que vai confirmar.

O denominador continua faltando, e isso é deliberado neste PR. Para contatos o /contacts devolve o tamanho da página no lugar do total — por isso o ContactsImporter declara reportsTotal = false. O seen que este PR adiciona é o melhor denominador disponível hoje, mas ele conta o que a origem entregou, não o que a origem tem. Se a paginação por offset pular uma linha, ela não aparece nem em seen nem em discarded. Fechar esse buraco depende de um total confiável na origem e não cabe aqui.

@daniloleonecarneiro
daniloleonecarneiro force-pushed the bms-7-levantar-por-que-o-script-atual-de-importacao-perde-contatos branch from ad53095 to 5185b8e Compare August 7, 2026 18:55
O progresso do job guardava done, total e page, e nada sobre o que foi descartado. Cada caminho de perda era um continue mudo ou um conflito engolido pelo ON CONFLICT, então uma importação que perdesse metade das linhas terminava com a mesma cara de uma que não perdeu nenhuma.

Passa a contar quatro motivos: linha recusada pelo customize ou por FK não resolvida, chave natural vazia, repetida dentro da página, e conflito engolido no insert. O último não dá para saber pelo retorno do insert — nenhum dos dois caminhos de escrita reporta quantas linhas gravou —, então é medido contando as linhas presentes depois da escrita. É o que revela a linha que passa pelo pré-filtro da chave natural e ainda perde para outra constraint única da tabela.

Passa a registrar também quantas linhas vieram da origem. Para contatos esse é o único denominador existente: o /contacts do Enterprise devolve o tamanho da página no lugar do total, e por isso o importer nem reporta total.

Um passo que leu linhas e não gravou nenhuma deixa de ser marcado como "empty" e passa a ser all_discarded — chamar isso de vazio era parte do que escondia a perda. O bloco de descartes é omitido quando não há nenhum, para que uma importação limpa continue limpa na tela.
@daniloleonecarneiro
daniloleonecarneiro force-pushed the bms-7-levantar-por-que-o-script-atual-de-importacao-perde-contatos branch from 5185b8e to bb72d35 Compare August 7, 2026 19:07
…er e corrige o all_discarded (BMS-18)

O all_discarded era inalcançável sempre que a origem informava total: o branch de
resume vinha primeiro e fechava o passo em done=totalKnown, marcando como
importado por inteiro um passo que não gravou nenhuma linha. Ler linhas e não
gravar nenhuma significa que todas foram descartadas, então essa decisão passa
na frente.

mapped_null contava dois casos com correção oposta: a linha que o customize
recusou e a linha cuja FK não resolveu em scope=account. A primeira é decisão de
qualidade de dado; a segunda quer dizer que um passo pai não importou o que a
linha aponta. Viram mapper_rejected e fk_unresolved.

Os tipos do msgops-api não tinham acompanhado seen e discarded. O progress é
repassado verbatim do jsonb, então nada quebrava, mas o tipo que documenta o
GET /imports/:jobId não mencionava o que ele devolve.

@filipecrosk filipecrosk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes before merge.

  1. totalDone still increases by candidates.length, not actual inserted rows. When every candidate loses to an ignored unique conflict, the step reports done rather than terminal all_discarded, masking the loss this change is meant to expose. Base the terminal state and progress on actual inserts, with an all-conflict regression case.

  2. all_discarded now records seen but no done; cleanupOrphanAccount() only treats done > 0 as progress. A later failure can therefore soft-delete an account whose job already recorded discarded source rows. Treat an existing/seen progress entry as progress, and add the cross-step failure regression.

totalDone += candidates.length somava linhas montadas, não as que
chegaram ao banco. Quando todo o lote de candidatas perde para o
orIgnore() por conflito de índice único, o passo fechava em done=N
com zero linha escrita — a exata leitura falsa que o contador de
descarte deveria expor. Passa a somar o que a página de fato tem no
banco ao final da transação (pré-existentes + inseridas líquidas), com
regressão de página inteira em conflito único caindo em all_discarded.
… órfão

cleanupOrphanAccount() só tratava done>0 como progresso, mas
all_discarded grava seen sem done — todo candidato foi lido e
descartado, o que é progresso real sobre dado que existia, não um
passo vazio. Uma falha num passo posterior soft-deletava a conta
mesmo com linhas de origem já consumidas. Passa a aceitar seen>0
também como sinal de progresso.

@filipecrosk filipecrosk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both requested correctness fixes are complete. Progress now counts rows actually present after the write instead of candidates, the all-conflict case terminates as , and orphan cleanup treats as real progress.\n\nValidation at :\n- Focused enterprise-import suites: 2 suites / 26 tests passed\n- enterprise-import type-check passed\n- msgops-api type-check passed\n\nApproved. (The repository currently reports no GitHub checks.)

@filipecrosk filipecrosk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both requested correctness fixes are complete. Progress now counts rows actually present after the write instead of candidates, the all-conflict case terminates as all_discarded, and orphan cleanup treats seen > 0 as real progress.

Validation at a8d98ba8d031d19216eb2288ddfb4917b2c391a4:

  • Focused enterprise-import suites: 2 suites / 26 tests passed
  • enterprise-import type-check passed
  • msgops-api type-check passed

Approved. (The repository currently reports no GitHub checks.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants