Insiden Produksi: Worker Crash Akibat Payload Siklik

Layanan API visualisasi arsitektur mikroservis mengalami crash berulang pada worker instance Node.js. Gejala diawali dengan lonjakan utilisasi CPU hingga 100% pada satu thread worker, disusul terputusnya koneksi HTTP klien secara mendadak dengan respons 502 Bad Gateway. Log aplikasi mencatat galat fatal berikut sebelum proses dihentikan oleh runtime:

RangeError: Maximum call stack size exceeded
    at serializeGraphRecursive (/app/src/services/graphSerializer.js:12:31)
    at serializeGraphRecursive (/app/src/services/graphSerializer.js:16:25)

Insiden terjadi sesaat setelah sistem menerima payload graf berarah (directed graph) yang merepresentasikan dependensi dependensi siklik antar-service, misalnya node Service-A -> Service-B -> Service-C -> Service-A. Fungsi serialisasi yang bertugas mengubah relasi graf menjadi struktur pohon visualisasi gagal keluar dari pemanggilan fungsi.

Root Cause Analysis: Rekursi Naif pada Call Stack V8

Call stack pada engine V8 memiliki limitasi kedalaman frame terbatas (secara default berkisar antara 10.000 pemanggilan bergantung pada ukuran stack frame dan arsitektur OS). Ketika relasi graf berbentuk acyclic (DAG), rekursi DFS dasar akan mencapai terminal node lalu melakukan backtracking.

Namun, jika graf memiliki siklus (cycle), kondisi dasar terminasi tidak pernah tercapai:

  • Frame Accumulation: Setiap pemanggilan rekursif menyisipkan alamat eksekusi, argumen, dan variabel lokal ke call stack memory.
  • CPU Thrashing: V8 terus-menerus mengalokasikan stack frame baru tanpa jeda I/O, mendominasi event loop single thread hingga CPU tercekik pada batas maksimal.
  • Process Death: Saat call stack mencapai limit memori yang dialokasikan OS/engine, V8 melempar RangeError yang tidak tertangkap, memicu uncaughtException dan mematikan worker process.

Strategi Remediasi

Penyelesaian masalah ini membutuhkan tiga modifikasi arsitektural pada fungsi traversal:

  1. Iterative Traversal (Manual Stack): Memindahkan alokasi dari Call Stack engine runtime ke Heap Memory menggunakan array internal. Heap memiliki kapasitas memori jauh lebih besar dibandingkan call stack frame limit.
  2. Cycle Detection via Visited Set: Menggunakan struktur data Set dengan kompleksitas pencarian O(1) untuk mencatat node yang telah diproses. Ketika referensi siklik ditemukan, node ditandai sebagai siklus tanpa mengekspansi anak-anaknya lebih lanjut.
  3. Defensive Depth Limit: Menetapkan ambang batas kedalaman (max depth) untuk mencegah konsumsi memori berlebih akibat struktur graf yang terlalu dalam atau anomali payload klien.

Perbandingan Implementasi Kode

Sebelum Perbaikan (Vulnerable Recursive DFS)

Fungsi rekursif berikut tidak memvalidasi siklus graf maupun batas kedalaman traversal:

function serializeGraphRecursive(nodeId, adjList) {
  const node = {
    id: nodeId,
    children: []
  };

  const neighbors = adjList[nodeId] || [];
  for (const neighborId of neighbors) {
    // Bug: Rekursi tanpa henti jika relasi memuat siklus (A -> B -> A)
    node.children.push(serializeGraphRecursive(neighborId, adjList));
  }

  return node;
}

Sesudah Perbaikan (Iterative DFS dengan Cycle & Depth Guard)

Pendekatan iteratif menggunakan array biasa sebagai stack pengganti call stack internal runtime, dilengkapi pelacakan siklus:

function serializeGraphIterative(rootId, adjList, maxDepth = 64) {
  if (!adjList[rootId]) {
    return { id: rootId, children: [], isCycle: false };
  }

  const visited = new Set();
  const rootNode = {
    id: rootId,
    children: [],
    isCycle: false
  };

  // Stack menyimpan referensi objek output dan kedalaman traversal saat ini
  // ponytail: stack manual di heap menghindari limit frame V8
  const stack = [{ parentNode: rootNode, depth: 0 }];
  visited.add(rootId);

  while (stack.length > 0) {
    const { parentNode, depth } = stack.pop();

    if (depth >= maxDepth) {
      continue;
    }

    const neighbors = adjList[parentNode.id] || [];
    for (let i = neighbors.length - 1; i >= 0; i--) {
      const neighborId = neighbors[i];
      const hasCycle = visited.has(neighborId);

      const childNode = {
        id: neighborId,
        children: [],
        isCycle: hasCycle
      };

      parentNode.children.push(childNode);

      // Node hanya dimasukkan ke stack jika belum pernah dikunjungi dan bukan siklus
      if (!hasCycle) {
        visited.add(neighborId);
        stack.push({ parentNode: childNode, depth: depth + 1 });
      }
    }
  }

  return rootNode;
}

Verifikasi: Runnable Unit Test

Pengujian mandiri menggunakan modul bawaan node:assert untuk memastikan fungsi dapat menangani dependensi siklik tanpa melempar RangeError.

const assert = require('node:assert');

// 1. Setup graf dengan relasi siklik: A -> B -> C -> A
const cyclicGraph = {
  'node-A': ['node-B'],
  'node-B': ['node-C'],
  'node-C': ['node-A', 'node-D'],
  'node-D': []
};

// 2. Eksekusi fungsi traversal terproteksi
const result = serializeGraphIterative('node-A', cyclicGraph, 10);

// 3. Verifikasi struktur data dan deteksi siklus
assert.strictEqual(result.id, 'node-A', 'Root ID harus node-A');
assert.strictEqual(result.children.length, 1, 'node-A harus memiliki 1 child');

const nodeB = result.children[0];
assert.strictEqual(nodeB.id, 'node-B', 'Child pertama harus node-B');
assert.strictEqual(nodeB.children.length, 1);

const nodeC = nodeB.children[0];
assert.strictEqual(nodeC.id, 'node-C', 'Child dari B harus node-C');
assert.strictEqual(nodeC.children.length, 2, 'node-C harus memiliki 2 child (A dan D)');

// Validasi node siklik A yang dihubungi dari C
const cyclicNodeA = nodeC.children.find(child => child.id === 'node-A');
assert.ok(cyclicNodeA, 'node-C harus mereferensikan kembali node-A');
assert.strictEqual(cyclicNodeA.isCycle, true, 'Flag isCycle pada node-A rekursif harus true');
assert.strictEqual(cyclicNodeA.children.length, 0, 'Node siklik tidak boleh diekspansi lagi');

// Validasi node valid D yang dihubungi dari C
const validNodeD = nodeC.children.find(child => child.id === 'node-D');
assert.ok(validNodeD, 'node-C harus tetap mengekspansi node-D');
assert.strictEqual(validNodeD.isCycle, false, 'Node D bukan siklus');

console.log('Semua test assertions lolos.');

Trade-off dan Limitasi Teknis

Meskipun pendekatan iteratif dengan pelacak siklus menyelesaikan RangeError, ada pertimbangan desain yang perlu diperhatikan:

  • Memory Overhead pada Heap: Penggunaan Set untuk melacak node yang dikunjungi mengonsumsi memori proporsional terhadap total node graf ($O(V)$). Untuk graf dengan jutaan node, pertimbangkan representasi sparse bitset atau integer ID buffer.
  • Representasi DAG vs General Directed Graph: Dalam directed acyclic graph yang valid, node yang sama bisa dicapai dari cabang berbeda tanpa membentuk siklus. Jika use case sistem mengizinkan node diekspansi berulang selama bukan direct ancestor cycle, gunakan recursion path Set (backtracking tracking) alih-alih global visited Set.
  • Depth Truncation: Pemotongan data visualisasi akibat maxDepth harus dikomunikasikan ke UI klien melalui metadata respons agar pengguna menyadari bahwa beberapa percabangan graf tidak ditampilkan secara utuh.