2023/02/01

Procesamiento de CSV mayor a 2GB con node

 Problemas, problemas, siempre tengo problemas.


¿Cuál es mi problema esta vez?

Un archivo CSV de 2M5 líneas, que al tener saltos de línea válidos dentro de algunos campos llega a 18M líneas de texto, ocupando 3.1 GB.

Es un listado de algo que no puedo decir, pero sí que me conviene procesarlo con javascript para hacerle una transformación a algunos campos selectos y agregar otros desde unas tablas, tipo una desnormalización. Esto es para luego meterlo en otra herramienta y ahí extraerle unas estadísticas.

De paso, al archivo resultante, según el valor de un registro, partirlo en varios en formato CSV.


¿Por qué una librería especial en lugar de parsearlo así nomás? Ocurre que si tenemos un CSV simple, donde no hay valores en los campos que coincidan con los delimitadores ni saltos de línea, es muy sencillo, tipo:

console.log("hola,como,estas,vos".split(/,/));

[ 'hola', 'como', 'estas', 'vos' ]

Pero si el CSV fuera

"mensaje,'hola, como estás vos', '2023-01-08 14:14'"

quedaría:

[ 'mensaje', "'hola", " como estás vos'", " '2023-01-08 14:14'" ]

distinto al esperado, no le prestes mucha atención a las comillas, que es un problema adicional:

[ 'mensaje', "hola, como estás vos", "2023-01-08 14:14" ]


Y sumale los saltos de línea para los campos multilínea... para que esto funcione bien hay que usar caracteres de escape, un bolonqui, mejor usar alguna librería existente, como la muy recomendada csvtojson.

 

Se instala con:

npm install --save csvtojson

Luego, anticipando que va a haber problemas con el tamaño, mirando los ejemplos me decidí por procesar línea a línea.

 

const request=require('request')
const csv=require('csvtojson')
 
csv().fromStream(request.get('http://mywebsite.com/mycsvfile.csv'))
.subscribe((json)=>{
  return new Promise((resolve,reject)=>{
    // transform and output operation
  })
},onError,onComplete);

 

Como quiero levantar el archivo localmente, reemplacé request con un stream reader o como se llame.

const request=require('fs')
const csv=require('csvtojson')
 

csv().fromStream(fs.createReadStream(fileName))
.subscribe((json)=>{
  return new Promise((resolve,reject)=>{
    // transform and output operation
  })
},onError,onComplete);

Para lidiar con la asincronía, probablemente de modo incorrecto, hice esto, si alguien me señala cualquier error, me avisa por favor:

 

const csvtojsonV2=require('csvtojson/v2');
const fs=require('fs');

class Processor {
  async process(fileName) {
    let setTitle = true;
    await csvtojsonV2().fromStream(fs.createReadStream(fileName))
    .subscribe( (json)=> {
      return new Promise((resolve,reject)=> {
        if (setTitle) {
          console.log(
             Object.keys(json).map(e=>`'${e}'`).join(',')
          );
          setTitle = false;
        }
        // transform
        console.log(
            Object.values(json).map(e=>`'${e}'`).join(',')
        );
        resolve()
      })
    },this.onError,this.onComplete)
  }
}

module.exports = { Processor }; 

Y con 

const {Processor} = require('./Processor.js');
const processor = new Processor();

allRecordsFileName=process.argv[2];

async function run() {
  let result = await processor.processExport(allRecordsFileName);
}

run()

 

funciona bien hasta que no funciona:

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

Buscando rapidito, hallé que con:

export NODE_OPTIONS="--max-old-space-size=7168"

llega un poco más lejos y falla, pero con más swapping. Como que necesitaría más memoria según todas las entradas que encontré en Internet.

Para mi que necesitar más memoria es una completa estupidez, hace falta saber más, por que la idea misma de usar el stream es que vaya leyendo y descartando para que tenga un impacto reducido, no que lea todo el archivo en memoria.


Dicho sea de paso, leí todo el archivo en memoria y falló igual:

const csv=require('csvtojson')
csv().fromFile(csvFilePath)
.then((jsonObj)=>{
    jsonObj.forEach(line=> {
       console.log(line);
    }
    );
})

Voy apostando a una de estas, en este orden:

  • implementé mal lo del manejo de las Promises, muy probable pues no sé usarlas, vale tirarme alguna pista...
  • hice mal uso del stream reader, lo mismo...
  • csv2json tiene un error relacionado al stream reader

 

 Pero csv2json sabe manejar archvios grandes de algún modo, pues con:

./node_modules/csvtojson/bin/csvtojson \
     < input.3GB.csv \
     > output.json

aunque tardó horrores, lo hizo, sabe lidiar con más de 2GB, problema que ya veremos....

 

Plan B

 

Siempre recordemos que no podemos leer línea a línea un CSV y pretender que sea correcto, pero si el output.json anterior está listo, es de una sóla línea por registro. Ahora necesitamos leer:

require('output.json');

nop, falla:

RangeError [ERR_FS_FILE_TOO_LARGE]: File size (5734037250) is greater than 2 GB

La solución con stream, falla igual en el createStreamRead():

const fs       = require('fs');
const readline = require('readline');
const stream   = require('stream');

var inputFileName=process.argv[2];

var input  = fs.createReadStream(inputFileName);
var output = new stream();
var rl     = readline.createInterface(input,output);

rl.on('line',function(line) {
  var record = JSON.parse(line);
  console.log(line);
});

 

Plan C


Se puede leer con request tal como está el ejemplo original, por algo lo habrán puesto, ¿no?, pero, maldición, está deprecated!... no importa, funciona ok, pero no soporta file:///, así que hay que armar un servidor efímero:

sudo php -S 127.0.0.1:80

Cada vez menos elegante....