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
NodeParserContextdanBabel/Acorn AST Nodemenempati 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
EventEmitterglobal 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:
- Gunakan
WeakMap: Simpan metadata yang mengaitkan AST ke node visual secara terpisah. KunciWeakMapberupa referensi objek yang dapat dibersihkan GC begitu instance target di-dereference. - Gunakan
AbortControlleratau Lifecycle Cleanup: Pasang listener dengan parametersignalatau sediakan methoddispose()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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!