O problema real da reindexação não é o volume, é que o catálogo continua mudando enquanto ela roda. Uma carga completa que leva oito horas é uma fotografia do instante inicial, e ao terminar já está oito horas desatualizada. Preencher esse buraco depois, com uma consulta por data de atualização, funciona mal: depende de relógio confiável, perde a exclusão de documento e ignora alteração feita por rotina que não toca o campo de data.
A solução é inverter a ordem. Primeiro liga a repetição de escrita no índice novo, depois começa a carga histórica. Toda alteração que acontece durante a carga já é aplicada nos dois índices, e a carga histórica preenche o passado por baixo. Como a escrita é idempotente por identificador de documento, a ordem entre a carga e a repetição não importa desde que a repetição use número de versão: um documento sobrescrito pela carga com uma versão mais antiga é descartado.
Fase 1 - so o indice antigo
aplicacao --escrita--> [INDICE ANTIGO] <--consulta-- aplicacao
Fase 2 - escrita dupla ligada, ainda sem carga historica
aplicacao --escrita--+--> [INDICE ANTIGO] <--consulta-- aplicacao
\--> [INDICE NOVO] (so deltas, vazio no passado)
verificacao: taxa de erro da escrita nova isolada, 0 impacto no caminho critico
Fase 3 - carga historica por baixo, com escrita dupla ligada
carregador --lote--> [INDICE NOVO] <--delta-- aplicacao
regra: escrita so aplica se versao maior que a ja indexada
verificacao: progresso por faixa de chave, nao por contagem total
Fase 4 - leitura sombra (consulta vai nos dois, responde o antigo)
aplicacao --consulta--+--> [INDICE ANTIGO] --> resposta ao usuario
\--> [INDICE NOVO] --> so comparacao, descartado
verificacao: sobreposicao das listas, latencia, ausencia de resultado vazio
Fase 5 - leitura percentual (1% -> 10% -> 50% -> 100%)
aplicacao --1%--> [INDICE NOVO] --> resposta ao usuario
-99%--> [INDICE ANTIGO] --> resposta ao usuario
verificacao: taxa de clique e busca sem clique por rota
Fase 6 - desligar
aplicacao --escrita+consulta--> [INDICE NOVO]
o indice antigo fica recebendo escrita, sem consulta, por um ciclo inteiroA fase dois parece inútil e é a que mais salva a migração. Ela não move leitura nenhuma: serve para provar que a escrita no motor novo funciona sob tráfego real, com o formato real dos documentos, incluindo aquele campo que só um fluxo raro preenche. Descobrir um mapeamento incompatível com zero leitura dependendo dele custa uma tarde. Descobrir depois de já ter migrado metade da leitura custa um incidente.
A escrita dupla precisa de uma regra clara sobre falha: o índice novo nunca pode derrubar a escrita principal. Enquanto ele não é fonte de leitura, a falha ali é registrada numa fila de reparo e não propagada. Depois que ele vira fonte de leitura, os papéis se invertem e o antigo passa a ser o lado tolerante. A escrita que falha silenciosamente e não vai para fila nenhuma é o defeito que produz divergência permanente, e é o mais comum dos três.
// Escrita dupla com versao e fila de reparo. O indice secundario nunca
// derruba a escrita principal: a falha vira item de reparo, nao excecao.
const PAPEIS = { PRIMARIO: 'primario', SECUNDARIO: 'secundario' };
// Configuracao dinamica: a troca de papel precisa valer sem reimplantacao,
// senao a reversao deixa de ser imediata.
const papelDe = (motor) => configuracao.get(`busca.papel.${motor.nome}`, PAPEIS.SECUNDARIO);
async function indexarDocumento(documento) {
// A versao vem da origem, nao do relogio local. Relogio entre processos
// diverge e faz a carga historica sobrescrever delta mais novo.
const payload = {
id: documento.id,
versao: documento.atualizadoEmSequencia,
corpo: montarDocumento(documento),
};
const resultados = await Promise.allSettled(
motoresAtivos().map((motor) => escreverComVersao(motor, payload)),
);
const falhas = resultados
.map((resultado, indice) => ({ resultado, motor: motoresAtivos()[indice] }))
.filter(({ resultado }) => resultado.status === 'rejected');
for (const { motor, resultado } of falhas) {
// Sempre enfileira para reparo, inclusive quando vai relancar:
// o reparo e o que garante convergencia se a nova tentativa falhar.
await filaDeReparo.enfileirar({
motor: motor.nome,
documentoId: documento.id,
versao: payload.versao,
erro: String(resultado.reason),
});
if (papelDe(motor) === PAPEIS.PRIMARIO) throw resultado.reason;
}
return { indexadoEm: motoresAtivos().length - falhas.length };
}
// escreverComVersao usa o controle de concorrencia do proprio motor:
// a escrita e recusada quando a versao enviada e menor que a indexada,
// e essa recusa e sucesso, nao erro.
async function escreverComVersao(motor, payload) {
try {
return await motor.index(payload, { ifVersionGreaterThan: payload.versao - 1 });
} catch (erro) {
if (erro.code === 'version_conflict') return { ignorado: true };
throw erro;
}
}
O tratamento do conflito de versão no fim é o detalhe que separa uma escrita dupla correta de uma que corrompe dados devagar. Durante a carga histórica, conflito de versão é o comportamento esperado e frequente: significa que um delta mais novo já chegou e a carga tentou sobrescrever com a foto antiga. Tratar isso como erro enche a fila de reparo de lixo e esconde as falhas reais. Tratar como sucesso ignorado é o que faz a convergência acontecer sozinha.