Fix treatment listing block-sort regression from per-row shared-number subqueries
Re-review of afa350d found that the correlated-subquery rewrite in
listLeads regressed any page landing in a large sort-tie block (e.g. the
default prioridade:asc sort's prioridade=4/score=0 bucket): SQLite's block
sort materializes the whole tied group before LIMIT/OFFSET apply, so the
shared-number subqueries got evaluated for the entire block instead of just
the page (measured 8-9x slower on page 2+ of a 60k-row fixture).
Split listLeads into two queries: a cheap id-only pass that does the
sorting/pagination (same cost as before the shared-number feature existed),
then a hydrate pass scoped to WHERE leads.id IN (...) for just that page's
ids, which is where the shared-number subqueries now run. getLead was
already a single-id lookup and didn't need this.
Also fixed the accompanying test, which only checked page 1 (a small,
already-sorted block) and so couldn't have caught the regression it was
meant to guard against; it now also checks a page landing inside the large
tied block.
This commit is contained in:
@@ -95,18 +95,37 @@ export function listLeads(db, filtro = {}) {
|
||||
const orderBy = SORTS[filtro.sort] || SORTS['prioridade:asc'];
|
||||
|
||||
const total = db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t;
|
||||
const rows = db.prepare(
|
||||
|
||||
// Pagina por id primeiro, sem as subconsultas de numero compartilhado: em
|
||||
// ordenacoes que so casam parcialmente com o indice (ex.: prioridade:asc),
|
||||
// o sort do SQLite materializa o bloco empatado inteiro antes do
|
||||
// LIMIT/OFFSET, o que reavaliaria as subconsultas para o bloco inteiro (ate
|
||||
// dezenas de milhares de linhas) em vez de so a pagina. Selecionando so o id
|
||||
// aqui, o custo desse sort volta a ser o mesmo de antes da funcionalidade de
|
||||
// numero compartilhado existir.
|
||||
const pageIds = db.prepare(
|
||||
`SELECT leads.id AS id
|
||||
FROM leads
|
||||
${whereSql}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?`
|
||||
).all(...params, pageSize, (page - 1) * pageSize).map((row) => row.id);
|
||||
|
||||
if (pageIds.length === 0) return { total, page, pageSize, rows: [] };
|
||||
|
||||
const placeholders = pageIds.map(() => '?').join(', ');
|
||||
const rowsById = new Map(db.prepare(
|
||||
`SELECT leads.id AS id, empresa, legal_name, whatsapp, ddd, cidade, uf, segmento, segmento_label,
|
||||
interesse, cnpj, cnae, score, score_qualidade, score_comercial, classificacao,
|
||||
prioridade, produto_principal, telefone_valido, origem,
|
||||
exported_at, captured_at, segments.affinity AS afinidade,
|
||||
${sharedNumberColumnsSql('leads.whatsapp')}
|
||||
FROM leads LEFT JOIN segments ON segments.name = leads.segmento
|
||||
${whereSql}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?`
|
||||
).all(...params, pageSize, (page - 1) * pageSize)
|
||||
.map((lead) => withSharedNumberState(withWhatsAppState(db, lead)));
|
||||
WHERE leads.id IN (${placeholders})`
|
||||
).all(...pageIds)
|
||||
.map((lead) => [lead.id, withSharedNumberState(withWhatsAppState(db, lead))]));
|
||||
|
||||
const rows = pageIds.map((id) => rowsById.get(id));
|
||||
|
||||
return { total, page, pageSize, rows };
|
||||
}
|
||||
|
||||
@@ -277,13 +277,36 @@ test('estado de numero compartilhado sai por linha, sem varrer a tabela inteira'
|
||||
);
|
||||
assert.ok(elapsedMs < 1_000, `listagem paginada levou ${elapsedMs.toFixed(1)}ms`);
|
||||
|
||||
// Prova estrutural na consulta real: a subconsulta correlacionada procura os
|
||||
// irmaos pelo indice do telefone, e a pagina e limitada no SQL.
|
||||
assert.ok(trace.sql, 'consulta real de listagem nao foi observada');
|
||||
assert.match(trace.sql, /LIMIT \? OFFSET \?/);
|
||||
// Prova estrutural na consulta real de hidratacao (a que traz as colunas de
|
||||
// numero compartilhado): ela busca so os ids ja paginados (nao faz o
|
||||
// LIMIT/OFFSET nem o sort ela mesma — isso acontece antes, na consulta so-id
|
||||
// — e a subconsulta correlacionada procura os irmaos pelo indice do
|
||||
// telefone.
|
||||
assert.ok(trace.sql, 'consulta de hidratacao nao foi observada');
|
||||
assert.match(trace.sql, /WHERE leads\.id IN \(/);
|
||||
const plan = db.prepare(`EXPLAIN QUERY PLAN ${trace.sql}`).all(...trace.args)
|
||||
.map((row) => row.detail).join('\n');
|
||||
assert.match(plan, /sharing USING [A-Z ]*INDEX idx_leads_whatsapp/i, plan);
|
||||
assert.doesNotMatch(plan, /SCAN sharing/i, plan);
|
||||
|
||||
// A ordenacao padrao (prioridade:asc, score:desc, cidade:asc, empresa:asc)
|
||||
// so casa parcialmente com o indice: os 60 mil leads de "ruido" empatam em
|
||||
// prioridade=4/score=0, um bloco unico que o sort do SQLite materializa
|
||||
// inteiro antes de aplicar LIMIT/OFFSET. A pagina 1 (os 3 leads especiais,
|
||||
// prioridade=1) nao entra nesse bloco e por isso nao prova nada sobre ele;
|
||||
// a pagina 2 cai direto nele. Se as subconsultas de numero compartilhado
|
||||
// fossem reavaliadas para o bloco inteiro durante o sort (em vez de so para
|
||||
// a pagina, apos paginar por id), essa consulta ficaria uma ordem de
|
||||
// grandeza mais lenta aqui.
|
||||
const page2StartedAt = performance.now();
|
||||
const page2 = listLeads(db, { pageSize: 3, page: 2 }).rows;
|
||||
const page2ElapsedMs = performance.now() - page2StartedAt;
|
||||
assert.equal(page2.length, 3);
|
||||
assert.ok(page2.every((row) => row.empresa.startsWith('Ruido')), page2.map((r) => r.empresa).join(','));
|
||||
assert.ok(
|
||||
page2ElapsedMs < elapsedMs * 5 + 200,
|
||||
`pagina 2 (dentro do bloco de 60 mil empatados) levou ${page2ElapsedMs.toFixed(1)}ms `
|
||||
+ `vs ${elapsedMs.toFixed(1)}ms na pagina 1 — sugere reavaliacao das subconsultas para o bloco inteiro`
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user