Lesson 0015 · FHIR · Modul Bulk Data · ±25 menit

Streaming baris per baris, batching, backpressure dan pipeline

Memproses file NDJSON gigabyte di Node dan menyimpannya ke database tanpa kehabisan memori, dengan fetch, stream, dan for await

Sebelumnya, di lesson 0014: jangan pernah memegang seluruh file; parse satu resource setiap kali. Lesson ini menunjukkan caranya: baca per baris, kumpulkan per batch, dan biarkan backpressure menahan pembacaan saat database lebih lambat. Seperti seluruh modul ini, tanpa lab: potongan kode hanya untuk dibaca dan dijelaskan.

Target lesson ini: Anda bisa menjawab "File Observation NDJSON 5 GB hasil $export. Bagaimana Anda memprosesnya di Node tanpa kehabisan memori?" dalam 90 detik: readline per baris, batching insert, stream-json untuk JSON besar non-NDJSON, backpressure (highWaterMark, drain), dan pipeline untuk error dan cleanup.

1. Streaming baris per baris, batching, dan stream-json

Versi paling pendek, persis pola resmi dokumentasi readline "Read file stream line-by-line": [1]

import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

const rl = createInterface({ input: createReadStream("Observation_1.ndjson"), crlfDelay: Infinity });
let batch: Observation[] = [];
for await (const line of rl) {
  if (!line.trim()) continue;
  batch.push(JSON.parse(line));                     // satu resource kecil, bukan seluruh file
  if (batch.length === 1000) { await db.insertMany(batch); batch = []; }
}
if (batch.length) await db.insertMany(batch);

JSON besar yang bukan NDJSON (mis. satu Bundle searchset 2 GB dari sistem lain) tidak bisa dibaca per baris, karena JSON biasa boleh punya newline di mana saja. Untuk itu ada stream-json: "Point it at a document far larger than available memory and it streams straight through", dan komponennya dirangkai dengan stream-chain yang "handles the streaming and backpressure". README-nya juga mengingatkan: dirancang untuk "data you own or trust", bukan input publik yang tidak tepercaya. [3]

import { parser } from "stream-json";
import { pick } from "stream-json/filters/pick.js";
import { streamArray } from "stream-json/streamers/stream-array.js";
import chain from "stream-chain";
import fs from "node:fs";

const entries = chain([
  fs.createReadStream("searchset-besar.json"),     // { "resourceType": "Bundle", "entry": [ ...jutaan... ] }
  parser(),
  pick({ filter: "entry" }),                        // turun ke array "entry", abaikan sisanya
  streamArray(),                                    // satu elemen array setiap kali: { key, value }
]);
entries.on("data", ({ value }) => handle(value.resource));

Pola dan import mengikuti contoh README stream-json 3.x (ESM, pick lalu streamArray). [3] README yang sama menyebut parser JSONL di stream-chain sebagai "the fast path for line-delimited data dumps".

Latihan 1 — Alat yang tepat

Membaca per baris sudah benar, tapi apa yang mencegah memori menumpuk saat database lebih lambat dari disk? Itu tugas backpressure.

2. Backpressure dan pipeline

Disk bisa memberi 500 MB/detik, database mungkin hanya menerima beberapa ribu baris per detik. Tanpa rem, data menumpuk di memori. Panduan Node menyebut ini backpressure: "a buildup of data behind a buffer during data transfer", dan di Node "streams have been the adopted solution". [4]

Versi produksi: unduh dari URL manifest, pecah per baris, batch ke database, semua dalam satu pipeline yang bisa di-await. pipeline dari node:stream/promises menerima async generator sebagai tahap transform dan fungsi async sebagai tujuan. [5]

import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";

async function* lines(source: AsyncIterable<string>) {
  let rest = "";
  for await (const chunk of source) {               // pull: chunk berikut dibaca setelah body loop selesai
    const parts = (rest + chunk).split("\n");
    rest = parts.pop()!;                            // sisa baris yang belum lengkap
    yield* parts;
  }
  if (rest) yield rest;
}

async function importFile(url: string, token: string) {
  const res = await fetch(url, { headers: { Accept: "application/fhir+ndjson", Authorization: `Bearer ${token}` } });
  if (!res.ok || !res.body) throw new Error(`unduh gagal: ${res.status}`);
  await pipeline(
    Readable.fromWeb(res.body).setEncoding("utf8"), // web stream → Node stream, decode UTF-8 dengan aman
    lines,
    async function (source: AsyncIterable<string>) {
      let batch: unknown[] = [];
      for await (const line of source) {
        if (!line.trim()) continue;
        batch.push(JSON.parse(line));
        if (batch.length === 1000) { await db.insertMany(batch); batch = []; }   // insert lambat = baca berhenti
      }
      if (batch.length) await db.insertMany(batch);
    },
  );                                                // error di tahap mana pun → reject + semua stream dibersihkan
}

Kenapa memori tetap kecil: async iterator stream bersifat pull. Selama await db.insertMany belum selesai, tidak ada yang meminta baris berikutnya, buffer terisi sampai highWaterMark, lalu pembacaan dari jaringan berhenti. Iterasi "will be read in chunks of size equal to the highWaterMark option". [5] setEncoding("utf8") mencegah karakter multibyte (mis. nama pasien beraksen) terpotong di batas chunk.

Latihan 2 — Backpressure

Latihan 3 — Ucapkan dengan suara keras

Pasang timer dan jawab dengan suara keras, seolah sedang interview. Jangan buka jawaban contoh sebelum selesai.

Jawaban contoh dan poin terakhir checklist juga menyebut jebakan presisi decimal; detailnya ada di lesson berikutnya, 0016.

"File Observation NDJSON 5 GB hasil $export. Bagaimana Anda memprosesnya di Node dan menyimpannya ke database tanpa kehabisan memori?" (90 detik)
Bandingkan dengan jawaban contoh dan checklist-nya

"Saya tidak akan readFile lalu JSON.parse: seluruh file harus jadi satu string, padahal panjang string di V8 dibatasi, parse-nya memblok event loop, dan NDJSON memang bukan satu dokumen JSON. Karena NDJSON satu baris satu resource, saya baca sebagai stream: createReadStream, pecah per baris dengan readline atau async generator, lalu JSON.parse per baris. Saya kumpulkan per batch, misalnya 1.000 resource, lalu await insert batch itu sebelum membaca lagi. Karena konsumen menarik data, stream berhenti membaca saat buffer mencapai highWaterMark; itulah backpressure, jadi memori tetap kecil walau database lebih lambat dari disk. Semua dirangkai dengan pipeline dari stream/promises, supaya error di tahap mana pun menghentikan dan membersihkan semua stream. Kalau datanya JSON biasa yang besar, misalnya Bundle, saya pakai stream-json. Dan saya hati-hati dengan decimal: JSON.parse mengubah 2.00 jadi 2."

Checklist: apakah Anda menyebut…

  • menolak readFile + JSON.parse seluruh file: batas panjang string/heap dan event loop terblok?
  • NDJSON = satu baris satu resource: stream (createReadStream) lalu JSON.parse per baris?
  • batching insert ke database dan await sebelum membaca lagi?
  • menjelaskan backpressure: highWaterMark, write() false / drain, konsumen lambat menahan produsen?
  • pipeline dari stream/promises untuk propagasi error dan cleanup?
  • menyebut stream-json untuk JSON besar non-NDJSON atau jebakan presisi decimal?

Lulus jika minimal 4 dari 6.

Berikutnya, lesson 0016: satu jebakan khas FHIR saat mem-parse baris-baris itu, yaitu angka decimal yang kehilangan presisi di JavaScript.

Istilah: GLOSSARY.md (bagian "Bulk Data dan JSON skala besar") · Indeks: semua lesson FHIR

Ada yang belum jelas? Tanyakan ke saya (agent) di terminal, misalnya: "apa beda Patient/$export dan Group/[id]/$export untuk use case analitik?", "bagaimana menulis hasil export ke S3 dengan backpressure yang benar?", atau "kapan lebih baik memakai worker thread daripada streaming?"

Sumber: [1] Node.js: Readline · [2] FHIR R4: ND-JSON Representation of Resources · [3] stream-json README · [4] Node.js Learn: Backpressuring in Streams · [5] Node.js: Stream