Skip to content

debugResultKey enumerates Buffer/TypedArray byte by byte, causing ~280x memory blowup per node result #1340

Description

@snakajima

Summary

When an agent returns a binary payload ({ buffer: Buffer }), debugResultKey walks it as a plain object and emits one debug key string per byte. Those keys are stored on TransactionLog.resultKeys and retained until the graph finishes, so a single 4MB buffer costs ~1.25GB of heap. In a mapAgent over N rows this scales with N and OOMs the process.

Found while chasing a memory-overflow report in mulmocast-cli: rendering a video grew by ~115MB per beat, and every beat's TTS mp3 / generated PNG is only a few hundred KB.

Reproduction

graphai 2.0.18, Node v22.18.0:

import { GraphAI } from "graphai";

const SIZE = 4 * 1024 * 1024; // 4MB, e.g. a generated image or a short mp4
const bigAgent = async () => ({ buffer: Buffer.alloc(SIZE, 1) });
const agents = { bigAgent: { agent: bigAgent, mock: bigAgent, name: "bigAgent", samples: [], description: "", category: [], author: "", repository: "", license: "MIT" } };

const before = process.memoryUsage();
const graph = new GraphAI({ version: 0.5, nodes: { gen: { agent: "bigAgent", inputs: {}, isResult: true } } }, agents);
await graph.run();
const after = process.memoryUsage();
const mb = (n) => Math.round(n / 1024 / 1024);
console.log("buffer size:", mb(SIZE), "MB");
console.log("heap+external delta:", mb(after.heapUsed + after.external - before.heapUsed - before.external), "MB");
console.log("resultKeys length:", graph.transactionLogs().find((l) => l.nodeId === "gen" && l.resultKeys)?.resultKeys.length);

Output:

buffer size: 4 MB
heap+external delta: 1253 MB
resultKeys length: 4194306

One key per byte — ":gen.buffer.0" … ":gen.buffer.4194303" — for a ~280x expansion of the payload.

Cause

src/utils/utils.ts, debugResultKeyInner:

if (Array.isArray(result)) {
  ...
}
return Object.keys(result).reduce((tmp, key) => {
  tmp[key] = debugResultKeyInner(result[key]);
  return tmp;
}, {});

A Buffer/Uint8Array is array-like but Array.isArray is false, so it falls through to the Object.keys() branch, and Object.keys on a typed array returns every index. objectToKeyArray then materializes an array and a joined string for each one.

The keys are attached in TransactionLog.onComplete (this.resultKeys = debugResultKey(...)), and the log lives for the duration of the run, so the expansion is retained rather than transient. Under mapAgent every row pays it, which is where the growth-with-N comes from.

Suggested fix

Skip binary views — there are no meaningful sub-keys to report for them:

const debugResultKeyInner = (result: ResultData): Record<string, unknown> => {
  if (result === null || result === undefined) return {};
  if (typeof result === "string") return {};
  if (ArrayBuffer.isView(result) || result instanceof ArrayBuffer) return {};
  ...
};

Verified against the repro above (patched in node_modules): heap+external delta goes from 1253MB to 4MB, and the node result itself is unaffected.

It may also be worth bounding the key walk generally (depth and/or total key count), since any large plain array hits the same shape — a 1M-element array of numbers produces 1M keys too.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions