Worker backend pada platform intelijen geospatial sering kali bertugas mengonsumsi, memvalidasi, dan mentransformasi data spasial mentah dari satelit atau feed publik. Masalah muncul ketika stream GeoJSON poligon masif berukuran ratusan megabyte hingga gigabyte diproses secara paralel. Alih-alih mempertahankan penggunaan memori yang stabil, worker Node.js kerap mengalami lonjakan V8 heap memory hingga memicu crash Out-Of-Memory (OOM) dengan status FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory.

Karakteristik Masalah dan Gejala

Pada implementasi worker OSINT geospatial (arsitektur pemrosesan stream terinspirasi dari stack analitik seperti eli-labz/Third-Eye), worker mengonsumsi pesan koordinat poligon via message broker, menormalisasi proyeksi spasial (EPSG:4326 ke EPSG:3857), lalu menyimpan hasilnya ke basis data spasial. Worker berjalan stabil saat beban normal, namun mengalami degradasi drastis saat memproses dataset poligon batas wilayah berskala besar.

Gejala teknis yang teramati pada pod worker:

  • Penggunaan resident set size (RSS) dan V8 heap naik secara linear tanpa pernah turun kembali ke baseline setelah tugas selesai.
  • Siklus Garbage Collection (GC) Major (Mark-Sweep-Compact) berjalan sangat sering dan memblokir event loop hingga latency melonjak tajam sebelum proses dimatikan paksa oleh container runtime.

Isolasi Root Cause dengan Heap Snapshot

Untuk melacak alokasi memori yang tertahan, worker dijalankan lokal atau pada staging dengan mengaktifkan flag inspektor Node.js:

node --inspect=0.0.0.0:9229 dist/worker.js

Alternatif lain tanpa membuka port debug adalah memicu snapshot secara terprogram menggunakan paket bawaan atau modul v8-profiler-next ketika memori melampaui batas ambang tertentu:

import v8 from 'node:v8';
import fs from 'node:fs';

function dumpHeap(tag) {
  const filename = `/tmp/heap-${tag}-${Date.now()}.heapsnapshot`;
  const snapshotStream = v8.getHeapSnapshot();
  const fileStream = fs.createWriteStream(filename);
  snapshotStream.pipe(fileStream);
}

Setelah mengambil dua snapshot pembanding (sebelum beban dan sesaat sebelum OOM) lalu memuatnya ke tab Memory di Chrome DevTools, perbandingan Summary dan Containment menunjukkan dua sumber retainment utama:

  1. Closure Scope Retention pada Event Listener: Poligon diurai menggunakan parser event-based. Callback featureStream.on('data', ...) secara tidak sengaja mereferensikan instance context analisis global yang menyimpan array metadata historis, mencegah GC membebaskan objek geometry yang telah diproses.
  2. Akumulasi Buffer Transformasi Koordinat: Transformasi titik poligon dialokasikan ulang ke dalam array multidimensi JavaScript standar tanpa destruksi eksplisit pada ring poligon kompleks yang memiliki jutaan simpul (vertices).

Refactoring: Stream Pipeline dan Penghapusan Retained State

Kode bermasalah yang menahan referensi di dalam closure:

// IMPLEMENTASI CACAT: Memory Leak via Closure dan Akumulasi Array In-Memory
import { EventEmitter } from 'node:events';

const auditTrail = []; // Tertahan di modul scope

export function processFeatures(inputStream) {
  inputStream.on('data', (chunk) => {
    const feature = JSON.parse(chunk);
    auditTrail.push(feature.properties); // Array tumbuh tak terkendali
    
    // Closure menahan seluruh scope fungsi luar
    transformCoordinates(feature.geometry.coordinates, (err, transformed) => {
      if (!err) saveToPostGIS(feature.id, transformed);
    });
  });
}

Perbaikan dilakukan dengan mengganti pemrosesan berbasis event biasa menjadi stream.pipeline dengan backpressure native via Transform stream, mengisolasi fungsi parsing, dan memastikan buffer array koordinat dibuang seketika.

// IMPLEMENTASI PERBAIKAN: Node.js Stream Pipeline dengan Backpressure Terjaga
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';

class CoordinateTransformer extends Transform {
  constructor(options = {}) {
    super({ ...options, objectMode: true });
  }

  _transform(feature, encoding, callback) {
    try {
      // Mutasi atau alokasi lokal tanpa closure luar
      const coordinates = feature.geometry.coordinates;
      const transformed = this.projectRing(coordinates);
      
      feature.geometry.coordinates = transformed;
      callback(null, feature);
    } catch (err) {
      callback(err);
    }
  }

  projectRing(coords) {
    // Iterasi linier tanpa membuat array wrapper berulang
    if (typeof coords[0] === 'number') {
      // Proyeksi titik individual [lon, lat]
      return [coords[0] * 20037508.34 / 180, coords[1]]; 
    }
    return coords.map((c) => this.projectRing(c));
  }
}

export async function runPipeline(inputStream, sinkWritable) {
  await pipeline(
    inputStream,
    new CoordinateTransformer(),
    sinkWritable
  );
}

Tuning Garbage Collection dan Runtime Node.js

Selain perbaikan kode, alokasi memori V8 disesuaikan agar worker tidak melakukan GC thrashing saat menangani throughput tinggi:

  • --max-old-space-size=4096: Mengalokasikan heap memory maksimum yang realistis dengan batas container memory.
  • Penghapusan referensi closure memungkinkan engine V8 membersihkan alokasi koordinat pada siklus GC minor (Scavenge) di New Space tanpa perlu dipromosikan ke Old Space.

Verifikasi Beban

Pengujian validasi dilakukan menggunakan simulasi dataset GeoJSON masif (500.000 poligon kompleks, file payload ~1.8 GB):

  1. Sebelum Perbaikan: Heap melonjak hingga batas default 1.4 GB dalam waktu 42 detik, CPU mencapai 100% akibat full GC compacting berturut-turut, diakhiri dengan crash OOM.
  2. Setelah Stream Pipeline: Heap memory stabil di kisaran 120 MB hingga 180 MB sepanjang 12 menit durasi ingestion. Backpressure dari sinkWritable berhasil menahan input stream saat proses transformasi atau I/O database melambat, mencegah antrean internal membengkak.