Pular para o conteúdo
Node.js

Node.js: exportando grandes volumes do Elasticsearch

Paloma Macetko
Ilustração colorida de um unicórnio de crina luminosa puxando um pergaminho infinito de dados de dentro de um baú de cristal, ao lado de uma coruja bibliotecária com lupa sobre uma planilha de linhas de luz, sob velas flutuantes

Olá meus Unicórnios! 🦄✨

Sabe quando o pedido parece o mais simples do mundo? 😅 "Me exporta esses registros num CSV." Era um índice do Elasticsearch, alguns milhões de documentos, e eu pensei: isso é uma busca e um laço, meia hora no máximo.

Foi aí que eu bati numa parede que o Elasticsearch levanta bem no meio do caminho — e que ele levanta de propósito, para o seu próprio bem. Descobri que o jeito óbvio de paginar simplesmente para de funcionar depois de um certo ponto, com um erro que não é nada óbvio.

Este artigo é o caminho completo: como exportar um índice inteiro para CSV com Node.js, por que a paginação normal não serve, e a armadilha que fica escondida depois que a exportação termina bonitinho. Sem nenhuma dependência — só o que já vem no Node.

🧱 A parede dos 10.000

Vamos começar pelo jeito que todo mundo tenta primeiro, porque é o jeito certo em qualquer outro banco: paginar com from e size. Pede os primeiros 5.000, depois os próximos 5.000, e vai empurrando o from para frente.

Funciona. Funciona lindamente… até você passar do documento número dez mil. Aí vem isto:

from=0    size=5000  -> ok, 5000 documentos (total 12500)
from=9000 size=1000  -> ok, 1000 documentos (total 12500)
from=10000 size=1000 -> HTTP 400 -> Result window is too large, from + size must be less than or equal to: [10000] but was [11000]

Repare no detalhe cruel: não é um limite de quantos documentos existem. O índice tem 12.500 ali, e ele mesmo diz isso no total. O limite é sobre até onde você pode empurrar a janela. O nome do parâmetro é index.max_result_window e o valor padrão dele é 10.000.

E por que essa parede existe? Porque from: 10000 obriga o Elasticsearch a ordenar e descartar os dez mil documentos anteriores em cada estilhaço, só para te entregar os mil seguintes. Quanto mais fundo você vai, mais caro fica — e um índice grande com um from alto derruba o nó de memória. A parede é o Elasticsearch te protegendo de si mesmo. 🙃

📜 A Scroll API, que é uma fotografia

O jeito certo de varrer um índice inteiro é a Scroll API. E a diferença de conceito é o que faz tudo encaixar, então vale parar um segundo aqui.

Uma busca normal é uma pergunta: "me dá os documentos 10.000 a 11.000". Cada pergunta é independente, e o Elasticsearch precisa se reposicionar do zero toda vez — é isso que fica caro lá no fundo.

O scroll é outra coisa: ele tira uma fotografia do índice e te entrega um marcador de página. Você abre o scroll uma vez, e depois só diz "próximo, próximo, próximo". O servidor sabe onde você parou, e não precisa contar nada de novo. Duas consequências ótimas:

  • O custo é o mesmo no primeiro lote e no milésimo — não tem ladeira.
  • Como é uma fotografia, o resultado não muda no meio da exportação. Documento gravado depois de você abrir o scroll não aparece, e documento apagado continua vindo. Para exportar, isso é exatamente o que se quer: um retrato consistente.

Abrir o scroll é uma busca comum com um parâmetro a mais na URL:

// Abre o scroll e devolve o primeiro lote.
async function abrirScroll() {
  const corpo = {
    size: TAMANHO_DO_LOTE,
    _source: CAMPOS,
    query: { match_all: {} },
    // Ordenar por _doc e a ordenacao mais barata que existe: o Elasticsearch
    // le na ordem em que os documentos estao no disco, sem calcular relevancia.
    sort: ["_doc"],
  };
  return pedir(
    "/" + INDICE + "/_search?scroll=" + TEMPO_DO_SCROLL,
    "POST",
    corpo
  );
}

Aquele sort: ["_doc"] não é enfeite, e é a otimização mais barata do artigo. Por padrão o Elasticsearch ordena por relevância — ele calcula uma nota para cada documento para saber quem vem primeiro. Numa exportação você não quer ranking nenhum, quer todo mundo. O _doc manda ler na ordem em que os documentos estão no disco, sem calcular nota de nada.

O _source é a segunda economia: pedindo só os campos que vão para o CSV, o Elasticsearch não precisa trafegar o documento inteiro. Num índice com campos grandes, isso sozinho já muda o tempo da exportação.

🔁 O laço, e o único jeito de saber que acabou

Com o scroll aberto, o laço pede lote após lote. Aqui mora a pergunta que me travou por uns minutos: como eu sei que acabou?

A resposta é menos elegante do que eu esperava — e é importante saber, porque a intuição erra:

    while (true) {
      const documentos = resposta.hits.hits;

      // O scroll acabou quando o lote volta vazio. Nao existe outro aviso:
      // o _scroll_id continua chegando igual mesmo no ultimo lote.
      if (documentos.length === 0) {
        break;
      }

      for (const documento of documentos) {
        arquivo.write(linha(documento._source));
      }

      total = total + documentos.length;
      console.log("Gravados " + total + " registros...");

      resposta = await proximoLote(scrollId);
      // O Elasticsearch pode devolver um scroll_id NOVO a cada lote. Guardar
      // sempre o ultimo evita pedir a pagina seguinte de um scroll que mudou.
      scrollId = resposta._scroll_id;
    }

O scroll acabou quando o lote volta vazio. Só isso. Não existe um campo terminou: true, e o _scroll_id continua chegando normalmente no último lote, igualzinho aos outros — se você esperar ele sumir para parar o laço, o laço não para nunca.

Repare também que eu regravo o scrollId a cada volta. Parece redundante, porque na prática ele quase sempre vem igual. Mas a documentação avisa que o identificador pode mudar entre os lotes, e no dia em que mudar, quem estiver reenviando o primeiro vai pedir a página seguinte de um scroll que não existe mais. É uma linha para evitar um bug que só aparece em produção. 😳

E tem um detalhe de memória escondido no laço: eu escrevo cada lote no arquivo na hora, com arquivo.write(). A tentação é juntar tudo num array e gravar no fim — e é assim que se estoura a memória do Node com um índice grande, porque você acaba com os milhões de registros todos na RAM de uma vez. O arquivo é um fluxo; use como fluxo.

🧹 O scroll que ninguém fecha

Agora a parte que eu mais quero que você leve deste artigo, porque é a que não dá erro nenhum. 🙏

Quando a exportação termina, está tudo certo, né? O CSV está lá, o total bate. Só que a fotografia continua aberta no servidor. Aquele retrato consistente que o Elasticsearch guardou para você custa memória, e ele segura essa memória até o tempo do scroll expirar.

Eu quis medir o tamanho do estrago em vez de acreditar. Rodei a exportação três vezes seguidas sem fechar o scroll, contando os contextos abertos no servidor depois de cada uma:

########## TRES EXPORTACOES **SEM** fecharScroll ##########
apos exportacao 1: {"contextos_abertos":1,"pico_de_contextos":1}
apos exportacao 2: {"contextos_abertos":2,"pico_de_contextos":2}
apos exportacao 3: {"contextos_abertos":3,"pico_de_contextos":3}

Um por exportação, empilhando, sem nunca baixar. E lembre que toda exportação terminou com sucesso — o CSV saiu perfeito nas três. Agora imagine isso num endpoint que roda várias vezes por dia. O vazamento não aparece no log de erro; aparece no gráfico de memória do cluster, semanas depois, e ninguém liga uma coisa à outra.

A cura é uma chamada só, e ela é literalmente um DELETE:

// Fecha o scroll no servidor.
async function fecharScroll(scrollId) {
  await pedir("/_search/scroll", "DELETE", { scroll_id: [scrollId] });
}

Com ela no lugar, as mesmas três exportações seguidas:

########## TRES EXPORTACOES **COM** fecharScroll ##########
apos exportacao 1: {"contextos_abertos":3,"pico_de_contextos":4}
apos exportacao 2: {"contextos_abertos":3,"pico_de_contextos":4}
apos exportacao 3: {"contextos_abertos":3,"pico_de_contextos":4}

O número para de subir. 🎉 (Os três que aparecem ali são justamente os que vazaram no teste anterior e continuam presos até expirar — a prova de que o vazamento é real e de que ele só sai sozinho com o tempo.)

🛟 Fechar mesmo quando dá errado

Só que tem um jeito muito fácil de escrever esse fechamento errado: colocar ele no fim do try. Se o servidor tossir no meio do laço — e numa exportação de milhões de registros, que demora, ele tosse — a exceção pula direto para o catch e o fechamento nunca acontece. Ou seja: justo quando você fez o cluster trabalhar mais, é quando você deixa o contexto aberto.

Por isso o fechamento vai no finally, que roda dos dois jeitos:

  } catch (erro) {
    console.error("Falhou: " + erro.message);
    process.exitCode = 1;
  } finally {
    // Fechar o scroll e obrigatorio, inclusive quando deu erro no meio: cada
    // scroll aberto segura memoria no servidor ate o tempo do scroll expirar.
    if (scrollId) {
      try {
        await fecharScroll(scrollId);
      } catch (erro) {
        console.error("Nao consegui fechar o scroll: " + erro.message);
      }
    }
    arquivo.end();
  }

Repare no try de dentro do finally. Ele parece paranoia, mas evita uma troca de erro bem cruel: se o fechamento também falhar, a exceção dele substituiria a exceção original — e você perderia a mensagem que explica o que de fato quebrou, trocando "no shard available" por "não consegui fechar o scroll". O erro que interessa é sempre o primeiro.

Derrubei o servidor no meio da exportação de propósito, para ver esse caminho acontecer:

Gravados 5000 registros...
Falhou: Elasticsearch respondeu 503: no shard available
exit=1
-- contextos abertos apos a falha (o finally fechou?) --
{"contextos_abertos":0,"pico_de_contextos":1}

É esse contextos_abertos: 0 que eu queria ver. A exportação falhou, o processo saiu com código 1 avisando que falhou, o CSV ficou pela metade — e mesmo assim o servidor ficou limpo. É o que separa um erro de um erro que deixa lixo para trás.

📄 O CSV, e as aspas que estragam tudo

Falta a parte boba que quebra exportação de verdade: escrever o CSV. Eu não instalei biblioteca para isso — são cinco linhas, e entender essas cinco linhas vale mais que a dependência.

// Transforma um valor em celula de CSV, protegendo virgula, aspas e quebra de linha.
function celula(valor) {
  if (valor === null || valor === undefined) {
    return "";
  }
  const texto = String(valor);
  // As aspas de dentro do texto viram duas aspas: e assim que o CSV escapa.
  return '"' + texto.replace(/"/g, '""') + '"';
}

// Monta uma linha do CSV na ordem dos campos pedidos.
function linha(documento) {
  const celulas = [];
  for (const campo of CAMPOS) {
    celulas.push(celula(documento[campo]));
  }
  return celulas.join(",") + "\n";
}

O problema do CSV é que a vírgula separa as colunas e pode estar dentro do dado. Um endereço como Rua das Flores, 120 vira duas colunas e desloca a planilha inteira dali para frente. Por isso toda célula sai entre aspas.

E aí nasce o segundo problema: e se o dado tiver aspas? A regra do CSV é ótima de tão estranha — uma aspa dentro do texto vira duas aspas. É o que aquele replace(/"/g, '""') faz. Sem essa linha, um único apelido escrito com aspas no meio do nome desalinha o arquivo inteiro e você só descobre quando alguém abre no Excel. 😅

🚀 O script inteiro

Junta tudo e é isto — um arquivo, de cima para baixo, sem nenhum npm install. O fetch e o fs já vêm no Node:

// Exporta um indice inteiro do Elasticsearch para CSV, usando a Scroll API.
// Sem dependencia nenhuma: fetch e fs ja vem no Node.
const fs = require("fs");

const ELASTIC = process.env.ELASTIC_URL || "http://localhost:9200";
const INDICE = "clientes";
const CAMPOS = ["nome", "cidade", "valor"];
const TAMANHO_DO_LOTE = 5000;
const TEMPO_DO_SCROLL = "5m";
const ARQUIVO = "clientes.csv";

// Uma requisicao ao Elasticsearch. Devolve o JSON ja convertido.
async function pedir(caminho, metodo, corpo) {
  const resposta = await fetch(ELASTIC + caminho, {
    method: metodo,
    headers: { "content-type": "application/json" },
    body: JSON.stringify(corpo),
  });
  const dados = await resposta.json();
  if (!resposta.ok) {
    // O erro do Elasticsearch vem dentro de "error.reason". Sem isso voce
    // recebe "[object Object]" e nao descobre o que aconteceu.
    const motivo = dados.error ? dados.error.reason : resposta.statusText;
    throw new Error("Elasticsearch respondeu " + resposta.status + ": " + motivo);
  }
  return dados;
}

// Transforma um valor em celula de CSV, protegendo virgula, aspas e quebra de linha.
function celula(valor) {
  if (valor === null || valor === undefined) {
    return "";
  }
  const texto = String(valor);
  // As aspas de dentro do texto viram duas aspas: e assim que o CSV escapa.
  return '"' + texto.replace(/"/g, '""') + '"';
}

// Monta uma linha do CSV na ordem dos campos pedidos.
function linha(documento) {
  const celulas = [];
  for (const campo of CAMPOS) {
    celulas.push(celula(documento[campo]));
  }
  return celulas.join(",") + "\n";
}

// Abre o scroll e devolve o primeiro lote.
async function abrirScroll() {
  const corpo = {
    size: TAMANHO_DO_LOTE,
    _source: CAMPOS,
    query: { match_all: {} },
    // Ordenar por _doc e a ordenacao mais barata que existe: o Elasticsearch
    // le na ordem em que os documentos estao no disco, sem calcular relevancia.
    sort: ["_doc"],
  };
  return pedir(
    "/" + INDICE + "/_search?scroll=" + TEMPO_DO_SCROLL,
    "POST",
    corpo
  );
}

// Pede o proximo lote do scroll ja aberto.
async function proximoLote(scrollId) {
  return pedir("/_search/scroll", "POST", {
    scroll: TEMPO_DO_SCROLL,
    scroll_id: scrollId,
  });
}

// Fecha o scroll no servidor.
async function fecharScroll(scrollId) {
  await pedir("/_search/scroll", "DELETE", { scroll_id: [scrollId] });
}

async function main() {
  const arquivo = fs.createWriteStream(ARQUIVO);
  arquivo.write(CAMPOS.join(",") + "\n");

  let scrollId = null;
  let total = 0;

  try {
    let resposta = await abrirScroll();
    scrollId = resposta._scroll_id;

    while (true) {
      const documentos = resposta.hits.hits;

      // O scroll acabou quando o lote volta vazio. Nao existe outro aviso:
      // o _scroll_id continua chegando igual mesmo no ultimo lote.
      if (documentos.length === 0) {
        break;
      }

      for (const documento of documentos) {
        arquivo.write(linha(documento._source));
      }

      total = total + documentos.length;
      console.log("Gravados " + total + " registros...");

      resposta = await proximoLote(scrollId);
      // O Elasticsearch pode devolver um scroll_id NOVO a cada lote. Guardar
      // sempre o ultimo evita pedir a pagina seguinte de um scroll que mudou.
      scrollId = resposta._scroll_id;
    }

    console.log("Pronto! " + total + " registros em " + ARQUIVO);
  } catch (erro) {
    console.error("Falhou: " + erro.message);
    process.exitCode = 1;
  } finally {
    // Fechar o scroll e obrigatorio, inclusive quando deu erro no meio: cada
    // scroll aberto segura memoria no servidor ate o tempo do scroll expirar.
    if (scrollId) {
      try {
        await fecharScroll(scrollId);
      } catch (erro) {
        console.error("Nao consegui fechar o scroll: " + erro.message);
      }
    }
    arquivo.end();
  }
}

main();

Para rodar, aponte para o seu servidor e mande ver:

ELASTIC_URL=http://localhost:9200 node exportar.js
Gravados 5000 registros...
Gravados 10000 registros...
Gravados 12500 registros...
Pronto! 12500 registros em clientes.csv

Os 12.500 registros saíram inteiros — dois mil e quinhentos a mais do que a paginação comum conseguiria alcançar, que era o problema todo lá no começo. 🎯

🎓 O que eu levo daqui

O que me pegou nessa história não foi a Scroll API em si, que é bem documentada. Foi perceber que o erro barulhento era o problema fácil. A parede dos 10.000 grita, mostra um HTTP 400 com a explicação escrita por extenso, e me obrigou a aprender o jeito certo em cinco minutos.

O problema difícil foi o silencioso: uma exportação que termina com sucesso, entrega o arquivo certo, e deixa memória presa no servidor sem uma linha de log. Esse eu só encontrei porque fui medir o que acontecia depois do "Pronto!" aparecer na tela.

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também