Diagnosa Masalah: OOM Crash pada Worker Visual Workflow

Engine visual workflow hybrid yang mengeksekusi Directed Acyclic Graph (DAG) berskala besar kerap menghadapi kendala alokasi memori. Masalah muncul ketika eksekusi alur kerja menggabungkan visual canvas node dengan evaluasi runtime blok kode teks (seperti JavaScript/TypeScript AST). Worker Node.js mendadak mengalami Out Of Memory (OOM) fatal JavaScript heap out of memory saat traversal dan eksekusi graph mencapai ratusan iterasi.

Masalah ini umumnya dipicu oleh retainment objek parsing AST dan subscription state store yang tidak dilepas selama lifecycle eksekusi node berlangsung.

Analisis Log dan Heap Snapshot

Pemeriksaan performa menggunakan flag --inspect dan snapshot engine V8 via Chrome DevTools mengidentifikasi dua anomali signifikan pada tab Retainers:

  • Constructor Retainer: Objek NodeParserContext dan Babel/Acorn AST Node menempati porsi heap terbesar tanpa terjangkau Garbage Collector (GC), tertahan oleh closure sinkronisasi canvas node.
  • Retaining Path: Ditemukan circular reference GraphNode.state -> ASTNode.scope -> GraphNode.
  • Listener Leaks: Instans EventEmitter global milik graph dispatcher menumpuk ribuan listener anonim yang memegang referensi ke environment eksekusi lokal.
(Garbage Collector Root)
  └─> GlobalWorkflowDispatcher (EventEmitter)
      └─> _events: { 'node:update': [Array of 5000+ Functions] }
          └─> Context Scope (Closure)
              └─> astCache: Map
                  └─> AST Node Reference
                      └─> parentGraphNode (Circular Retainer)

Akar Masalah: Circular Reference dan Event Listener Leak

Saat kode tekstual diurai menjadi AST, parser sering mengikat instance visual node asli ke metadata node AST untuk keperluan dynamic error reporting atau mapping breakpoint dua arah. Jika instance visual node juga menyimpan referensi pohon AST hasil parsing di state lokalnya, siklus referensi terbentuk.

Jika lifecycle listener tidak diikat ke penghancuran node, referensi lokal dalam closure tidak akan pernah memenuhi kualifikasi garbage collection V8.

Kode Bermasalah (Anti-Pattern)

// Bad: Circular reference dan unreleased global listener
class VisualWorkflowNode {
  constructor(id, graphDispatcher) {
    this.id = id;
    this.dispatcher = graphDispatcher;
    this.state = {};
    this.astTree = null;

    // Leak 1: Listener anonim global menahan 'this' selamanya
    this.dispatcher.on('state:sync', (payload) => {
      this.syncState(payload);
    });
  }

  parseCodeBlock(rawCode) {
    // Simulasi AST parsing
    const ast = parseScript(rawCode);

    // Leak 2: Circular reference manual
    ast.sourceNode = this; 
    this.astTree = ast; 
  }

  syncState(payload) {
    if (payload.nodeId === this.id) {
      this.state = { ...this.state, ...payload.data };
    }
  }
}

Solusi: Decoupling Menggunakan WeakMap dan AbortController

Untuk memutus circular reference tanpa mengorbankan performa sinkronisasi state, terapkan dua pola perbaikan:

  1. Gunakan WeakMap: Simpan metadata yang mengaitkan AST ke node visual secara terpisah. Kunci WeakMap berupa referensi objek yang dapat dibersihkan GC begitu instance target di-dereference.
  2. Gunakan AbortController atau Lifecycle Cleanup: Pasang listener dengan parameter signal atau sediakan method dispose() eksplisit untuk memutus keterikatan listener saat scope node selesai dieksekusi.

Kode Perbaikan (Refactored)

// Good: Decoupled metadata storage dan explicit cleanup
const astMetadataMap = new WeakMap();

class CleanWorkflowNode {
  constructor(id, graphDispatcher) {
    this.id = id;
    this.dispatcher = graphDispatcher;
    this.state = {};
    this.abortController = new AbortController();

    this.initListeners();
  }

  initListeners() {
    const { signal } = this.abortController;
    
    // Melepas listener otomatis via AbortSignal
    this.dispatcher.addEventListener('state:sync', (event) => {
      this.syncState(event.detail);
    }, { signal });
  }

  parseCodeBlock(rawCode) {
    const ast = parseScript(rawCode);

    // Metadata disimpan decoupled di WeakMap, memutus circular reference
    astMetadataMap.set(ast, { nodeId: this.id });
    
    // Jangan simpan seluruh AST jika hanya membutuhkan snapshot struktural
    this.state.astRootHash = ast.hash;
    return ast;
  }

  syncState(payload) {
    if (payload.nodeId === this.id) {
      this.state = structuredClone(payload.data);
    }
  }

  dispose() {
    // Trigger pembersihan event listener
    this.abortController.abort();
    this.state = null;
  }
}

Unit Test: Verifikasi Retensi Memori

Gunakan runtime Node.js dengan flag --expose-gc untuk menguji apakah pembersihan memori bekerja sesuai rancangan.

// test/memory-leak.test.js
import { strictEqual } from 'node:assert';
import { EventTarget } from 'node:events';

function runMemoryVerificationTest() {
  if (typeof global.gc !== 'function') {
    throw new Error('Jalankan test menggunakan node --expose-gc');
  }

  const dispatcher = new EventTarget();
  let weakRef;

  // Jalankan alokasi dalam isolasi scope fungsi
  (() => {
    const node = new CleanWorkflowNode('node_101', dispatcher);
    node.parseCodeBlock('const x = 10;');
    
    // Tangkap referensi tanpa menghalangi GC
    weakRef = new WeakRef(node);
    
    // Bersihkan node
    node.dispose();
  })();

  // Paksa garbage collection
  global.gc();

  // Verifikasi objek berhasil di-reclaim
  strictEqual(weakRef.deref(), undefined, 'VisualWorkflowNode masih tertahan di heap memory');
  console.log('PASS: Memory verification passed, references collected properly.');
}

runMemoryVerificationTest();

Trade-off dan Rekomendasi Eksekusi

Pilihan antara WeakMap dan deep copy (seperti structuredClone) membawa implikasi teknis yang berbeda:

  • WeakMap: Biaya performa mendekati nol ($O(1)$ read/write). Cocok untuk metadata parsing runtime dan binding context transient yang sering diakses berulang kali.
  • structuredClone: Mengisolasi total referensi internal antar-state, tetapi memiliki trade-off CPU cost dan serialisasi traversal pada pohon graph yang memiliki depth tinggi.

Terapkan WeakMap pada metadata transient engine visual workflow dan pastikan seluruh event bridge global menerapkan interface deregister atau sinyal abort sebelum mengeksekusi DAG berikutnya.