Fix export excel writer crashing the process on a stream write error

exportExcel's CSV/XLS write streams had no 'error' listener, so a write
failure (disk full, export directory becomes inaccessible mid-run — both
realistic for a large CNPJ export) surfaced as an uncaught exception that
crashed the whole export-job.mjs child process, bypassing every
await/try-catch in the caller. The exports row was then never marked
'error' and stayed stuck, and a half-written file could sit at the final
path (no temp+rename, unlike the same pattern already used in
rfb-extractor.mjs/rfb-downloader.mjs).

Streams now track their error via a listener instead of throwing, writes
and stream-end are rejected through the normal promise chain so
export-job.mjs's existing catch/finally handles it, and both files are
written to .tmp paths and only renamed to their final names once both
streams close successfully.

Found via a full-project audit.
This commit is contained in:
Gustavo
2026-07-31 22:13:49 -03:00
parent 0bae79e1aa
commit 3d552b5bc0
2 changed files with 91 additions and 25 deletions
+70 -25
View File
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { csvEscape, excelTextCell, neutralizeSpreadsheetFormula, BOM } from '../util/csv.mjs';
import { iterateLeads } from '../treatment/queries.mjs';
@@ -54,50 +55,94 @@ function escapeHtml(value) {
.replace(/>/g, '>');
}
async function writeStream(stream, text) {
// Rastreia o evento 'error' do stream sem lancar (evita a excecao nao
// capturada que derrubaria o processo inteiro): writeStream/endStream
// verificam esse estado e transformam a falha numa promise rejeitada, que o
// try/catch normal deste modulo (e do export-job.mjs) consegue tratar.
function trackStreamError(stream) {
let error = null;
stream.on('error', (streamError) => { error = streamError; });
return () => error;
}
async function writeStream(stream, text, getError) {
if (getError()) throw getError();
if (!stream.write(text)) {
await new Promise((resolve) => stream.once('drain', resolve));
await new Promise((resolve, reject) => {
stream.once('drain', resolve);
stream.once('error', reject);
});
}
}
async function endStream(stream, getError) {
if (getError()) throw getError();
await new Promise((resolve, reject) => {
stream.once('error', reject);
stream.end((error) => (error ? reject(error) : resolve()));
});
}
export async function exportExcel({ db, filtro, dir, onProgress = () => {}, collectIds }) {
fs.mkdirSync(dir, { recursive: true });
const baseName = 'leads-excel';
const csvPath = path.join(dir, `${baseName}.csv`);
const xlsPath = path.join(dir, `${baseName}.xls`);
// Grava em .tmp e so promove (rename) pro nome final se as duas escritas
// terminarem com sucesso — nunca deixa um arquivo pela metade no lugar do
// arquivo final, mesmo caminho ja usado em rfb-extractor.mjs/rfb-downloader.mjs.
const csvTmpPath = `${csvPath}.tmp`;
const xlsTmpPath = `${xlsPath}.tmp`;
const csv = fs.createWriteStream(csvPath, { encoding: 'utf8' });
const xls = fs.createWriteStream(xlsPath, { encoding: 'utf8' });
await writeStream(csv, BOM + HEADERS.join(';') + '\n');
await writeStream(xls, `${BOM}<html><head><meta charset="utf-8"><style>td.text{mso-number-format:"\\@";}</style></head><body><table border="1"><tr>${
HEADERS.map((h) => `<th>${escapeHtml(h)}</th>`).join('')
}</tr>\n`);
const csv = fs.createWriteStream(csvTmpPath, { encoding: 'utf8' });
const xls = fs.createWriteStream(xlsTmpPath, { encoding: 'utf8' });
const csvError = trackStreamError(csv);
const xlsError = trackStreamError(xls);
let total = 0;
for (const lead of iterateLeads(db, filtro)) {
const row = leadToExportRow(lead);
try {
await writeStream(csv, BOM + HEADERS.join(';') + '\n', csvError);
await writeStream(xls, `${BOM}<html><head><meta charset="utf-8"><style>td.text{mso-number-format:"\\@";}</style></head><body><table border="1"><tr>${
HEADERS.map((h) => `<th>${escapeHtml(h)}</th>`).join('')
}</tr>\n`, xlsError);
const csvCells = HEADERS.map((h) => (TEXT_COLUMNS.has(h) && row[h] !== ''
? excelTextCell(row[h])
: csvEscape(neutralizeSpreadsheetFormula(row[h]))));
await writeStream(csv, csvCells.join(';') + '\n');
for (const lead of iterateLeads(db, filtro)) {
const row = leadToExportRow(lead);
const xlsCells = HEADERS.map((h) =>
`<td${TEXT_COLUMNS.has(h) ? ' class="text"' : ''}>${escapeHtml(TEXT_COLUMNS.has(h) ? row[h] : neutralizeSpreadsheetFormula(row[h]))}</td>`
);
await writeStream(xls, `<tr>${xlsCells.join('')}</tr>\n`);
const csvCells = HEADERS.map((h) => (TEXT_COLUMNS.has(h) && row[h] !== ''
? excelTextCell(row[h])
: csvEscape(neutralizeSpreadsheetFormula(row[h]))));
await writeStream(csv, csvCells.join(';') + '\n', csvError);
if (collectIds) collectIds.push(lead.id);
total += 1;
if (total % 10000 === 0) onProgress({ message: `${total} leads gravados...` });
const xlsCells = HEADERS.map((h) =>
`<td${TEXT_COLUMNS.has(h) ? ' class="text"' : ''}>${escapeHtml(TEXT_COLUMNS.has(h) ? row[h] : neutralizeSpreadsheetFormula(row[h]))}</td>`
);
await writeStream(xls, `<tr>${xlsCells.join('')}</tr>\n`, xlsError);
if (collectIds) collectIds.push(lead.id);
total += 1;
if (total % 10000 === 0) onProgress({ message: `${total} leads gravados...` });
}
await writeStream(xls, '</table></body></html>\n', xlsError);
await Promise.all([
endStream(csv, csvError),
endStream(xls, xlsError)
]);
} catch (error) {
csv.destroy();
xls.destroy();
await Promise.all([
fsp.rm(csvTmpPath, { force: true }),
fsp.rm(xlsTmpPath, { force: true })
]);
throw error;
}
await writeStream(xls, '</table></body></html>\n');
await Promise.all([
new Promise((resolve, reject) => csv.end((e) => (e ? reject(e) : resolve()))),
new Promise((resolve, reject) => xls.end((e) => (e ? reject(e) : resolve())))
fsp.rename(csvTmpPath, csvPath),
fsp.rename(xlsTmpPath, xlsPath)
]);
return {
+21
View File
@@ -45,6 +45,27 @@ test('exportExcel usa nome interno e neutraliza formulas no CSV/XLS', async () =
}
});
// Antes, um erro no stream de escrita (disco cheio, pasta inacessivel) virava
// excecao nao capturada e derrubava o processo do job inteiro, sem nunca
// chegar ao catch de export-job.mjs; e o arquivo final podia ficar pela
// metade no lugar do arquivo definitivo. Forca EISDIR criando um diretorio
// no caminho .tmp esperado do CSV, no lugar de um disco cheio de verdade.
test('exportExcel rejeita (nao derruba o processo) quando o stream falha, e nao deixa arquivo pela metade', async () => {
const db = openDb(':memory:');
seedLead(db);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pessoal-export-falha-'));
try {
await fs.mkdir(path.join(dir, 'leads-excel.csv.tmp'));
await assert.rejects(() => exportExcel({ db, filtro: {}, dir }));
assert.equal(await fs.stat(path.join(dir, 'leads-excel.csv')).then(() => true, () => false), false);
assert.equal(await fs.stat(path.join(dir, 'leads-excel.xls')).then(() => true, () => false), false);
assert.equal(await fs.stat(path.join(dir, 'leads-excel.xls.tmp')).then(() => true, () => false), false);
} finally {
await fs.rm(dir, { recursive: true, force: true });
db.close();
}
});
test('rota de exportacao rejeita tipos corporativos removidos', async () => {
const db = openDb(':memory:');
seedLead(db);