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:
+70
-25
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user