← Recipes

阅读中文版本

Transform Gigabyte Files with Constant Memory Streams

Replace readFileSync loops with pipeline streams, apply backpressure-aware transforms, and verify output equivalence on a sample.

~35 mindatanodejsstreaming

Task

Convert a whole-file transform into a streaming pipeline that holds memory flat at any input size and never drops rows.

When to use this

The nightly scrub reads a 3GB export into memory, peaks at 4GB RSS, and gets OOM-killed whenever the file grows. It worked for two years until it silently did not.

Prerequisites

  • Node.js 18 or newer
  • A large line-oriented file (JSONL, CSV) to transform
  • A row-level checksum baseline from a small sample

Steps

1. Establish the current memory ceiling with the whole-file version.

node -e "const fs=require('fs');const d=fs.readFileSync(process.argv[1],'utf8');console.log('read', (d.length/1e6).toFixed(0)+'MB')" big.jsonl; ps -o rss= -p $$ | awk '{print "peak RSS ~", $1/1024, "MB"}'

Expected output: The read prints the file size and the RSS snapshot shows memory proportional to input, confirming the scaling problem.

2. Rewrite as a pipeline with a transform stream.

import { createReadStream, createWriteStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';

const scrub = new Transform({
  transform(line, _enc, cb) {
    if (!line.trim()) return cb();
    try {
      const row = JSON.parse(line);
      row.email = row.email?.toLowerCase().trim() ?? null;
      row.ssn = '[redacted]';
      this.push(JSON.stringify(row) + '\n');
      cb();
    } catch (err) {
      cb(new Error(`bad line: ${line.slice(0, 80)}`)); // fail loud, never skip
    }
  },
});

await pipeline(
  createReadStream(process.argv[2], { encoding: 'utf8' }),
  createInterface({ crlfDelay: Infinity }), // readline is a valid pipeline leg
  scrub,
  createWriteStream(process.argv[3]),
);

Expected output: Running the pipeline on the same file completes with RSS pinned near baseline regardless of input size.

3. Watch memory stay flat while throughput stays high.

node scrub-stream.mjs big.jsonl out.jsonl & PID=$!; for i in 1 2 3 4; do ps -o rss= -p $PID | awk '{printf "RSS %dMB\n", $1/1024}'; sleep 30; done; wait $PID; wc -l out.jsonl

Expected output: RSS samples print flat (tens of MB) while the file processes, and the output line count matches the input minus blank lines.

4. Verify equivalence on a sample before swapping the job.

head -n 10000 big.jsonl > sample.jsonl && node scrub-wholefile.mjs sample.jsonl sample-old.jsonl && node scrub-stream.mjs sample.jsonl sample-new.jsonl && diff <(jq -S . sample-old.jsonl) <(jq -S . sample-new.jsonl) && echo "outputs equivalent"

Expected output: The sorted, normalized sample outputs match byte-for-byte, proving the stream version is behaviorally identical on the comparison set.

5. Add integrity guards to the production job.

#!/usr/bin/env bash
set -euo pipefail
IN="$1"; OUT="$2"
in_lines=$(grep -c . "$IN" || true)
node scrub-stream.mjs "$IN" "$OUT"
out_lines=$(grep -c . "$OUT" || true)
if [ "$in_lines" -ne "$out_lines" ]; then
  echo "row count mismatch: in=$in_lines out=$out_lines" >&2
  exit 1
fi
echo "OK: $out_lines rows transformed"

Expected output: The nightly job now fails loudly on any row count mismatch instead of writing a silently truncated file and reporting success.

Troubleshooting

Problem

Transform writes garbled multi-byte characters

Solution

A chunk boundary split a UTF-8 sequence; either set encoding on the read stream and operate on strings, or wrap with a StringDecoder in the transform.

Problem

Pipeline aborts mid-file with EMFILE

Solution

Too many open handles elsewhere; the pipeline itself opens one stream, so audit concurrent jobs or raise ulimit -n, and rely on pipeline to close streams on error.

Problem

Throughput is worse than the whole-file version

Solution

Per-line JSON.parse dominates; batch the transform with a highWaterMark-sized buffer, parsing and pushing every N lines to amortize per-call overhead.

Limitations

  • Line-oriented streaming assumes record boundaries; arbitrary binary formats need framing logic before transforms apply.
  • Constant memory does not mean zero cost: backpressure propagates and total runtime is bounded by the slowest leg.

Memory proportional to input is a scheduled outage

Any transform whose RSS scales with file size works exactly until the file outgrows the box. The streaming pipeline holds memory flat: read a chunk, transform, push, let backpressure pace the whole chain. The rewrite is smaller than the outage it prevents.

whole-file version, 3GB input:
  readFileSync -> 4.1GB RSS -> OOMKill (exit 137) at 3.2GB file

streaming pipeline, same input:
  00:00 RSS 48MB
  01:30 RSS 51MB
  03:12 RSS 49MB   (file finished, 3GB -> 2.7GB scrubbed)

row guard: in=41,923,004  out=41,923,004  -> OK

Backpressure is the feature

pipeline does not just chain streams: it propagates pressure. When the disk write lags, the read slows instead of buffering the entire file in RAM. Whole-file code has no such brake, which is precisely why it dies at scale rather than degrading.

Fail loud, never skip

The tempting catch-and-continue in a scrub loop writes truncated output with a success exit code. Route parse errors into cb(err) so the pipeline aborts, and let the row-count guard catch physical truncation. A data job that can fail silently cannot be trusted to run unattended.