Skip to content

Commit 2cfecd2

Browse files
tomiclaude
andauthored
perf(core): Use streaming JSON parsing for large execution data (#25799)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cadfa4c commit 2cfecd2

9 files changed

Lines changed: 407 additions & 17 deletions

File tree

packages/@n8n/backend-common/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,17 @@
2727
"@n8n/decorators": "workspace:*",
2828
"@n8n/di": "workspace:*",
2929
"callsites": "catalog:",
30+
"flatted": "catalog:",
3031
"n8n-workflow": "workspace:*",
32+
"stream-json": "catalog:",
3133
"picocolors": "catalog:",
3234
"reflect-metadata": "catalog:",
3335
"winston": "3.14.2",
3436
"yargs-parser": "21.1.1"
3537
},
3638
"devDependencies": {
3739
"@n8n/typescript-config": "workspace:*",
40+
"@types/stream-json": "1.7.8",
3841
"@types/yargs-parser": "21.0.0",
3942
"zod": "catalog:"
4043
}

packages/@n8n/backend-common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export type { ModuleName } from './modules/modules.config';
99
export { ModulesConfig } from './modules/modules.config';
1010
export { isContainedWithin, safeJoinPath } from './utils/path-util';
1111
export { assertDir, exists } from './utils/fs';
12+
export { parseFlatted } from './utils/parse-flatted';
1213
export { CliParser } from './cli-parser';
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { parse, stringify } from 'flatted';
2+
3+
import { parseFlattedAsync } from '../flatted-async';
4+
5+
describe('parseFlattedAsync', () => {
6+
it('should parse simple objects', async () => {
7+
const original = { name: 'test', count: 42, active: true };
8+
const flattedString = stringify(original);
9+
const result = await parseFlattedAsync(flattedString);
10+
expect(result).toEqual(original);
11+
});
12+
13+
it('should parse nested objects', async () => {
14+
const original = {
15+
node1: { data: [1, 2, 3], meta: { type: 'trigger' } },
16+
node2: { data: ['a', 'b'], meta: { type: 'action' } },
17+
};
18+
const flattedString = stringify(original);
19+
const result = await parseFlattedAsync(flattedString);
20+
expect(result).toEqual(original);
21+
});
22+
23+
it('should handle circular references', async () => {
24+
const original: Record<string, unknown> = { name: 'root' };
25+
original.self = original;
26+
27+
const flattedString = stringify(original);
28+
const result = await parseFlattedAsync(flattedString);
29+
30+
expect((result as Record<string, unknown>).name).toBe('root');
31+
expect((result as Record<string, unknown>).self).toBe(result);
32+
});
33+
34+
it('should handle arrays with circular references', async () => {
35+
const arr: unknown[] = [1, 2, 3];
36+
arr.push(arr);
37+
38+
const flattedString = stringify(arr);
39+
const result = await parseFlattedAsync(flattedString);
40+
41+
expect(Array.isArray(result)).toBe(true);
42+
const resultArr = result as unknown[];
43+
expect(resultArr[0]).toBe(1);
44+
expect(resultArr[1]).toBe(2);
45+
expect(resultArr[2]).toBe(3);
46+
expect(resultArr[3]).toBe(resultArr);
47+
});
48+
49+
it('should handle null and primitive values', async () => {
50+
expect(await parseFlattedAsync(stringify(null))).toBeNull();
51+
expect(await parseFlattedAsync(stringify(42))).toBe(42);
52+
expect(await parseFlattedAsync(stringify('hello'))).toBe('hello');
53+
expect(await parseFlattedAsync(stringify(true))).toBe(true);
54+
});
55+
56+
it('should handle empty objects and arrays', async () => {
57+
expect(await parseFlattedAsync(stringify({}))).toEqual({});
58+
expect(await parseFlattedAsync(stringify([]))).toEqual([]);
59+
});
60+
61+
it('should produce the same result as flatted.parse for complex data', async () => {
62+
const original = {
63+
resultData: {
64+
runData: {
65+
Node1: [
66+
{
67+
startTime: 1234567890,
68+
executionTime: 100,
69+
data: { main: [[{ json: { id: 1, name: 'item1' } }]] },
70+
},
71+
],
72+
Node2: [
73+
{
74+
startTime: 1234567990,
75+
executionTime: 200,
76+
data: { main: [[{ json: { id: 2, name: 'item2', nested: { deep: true } } }]] },
77+
},
78+
],
79+
},
80+
},
81+
executionData: {
82+
contextData: {},
83+
nodeExecutionStack: [],
84+
metadata: {},
85+
waitingExecution: {},
86+
waitingExecutionSource: {},
87+
},
88+
};
89+
90+
const flattedString = stringify(original);
91+
const syncResult = parse(flattedString);
92+
const asyncResult = await parseFlattedAsync(flattedString);
93+
94+
expect(asyncResult).toEqual(syncResult);
95+
});
96+
97+
it('should handle shared references between objects', async () => {
98+
const shared = { key: 'shared-value' };
99+
const original = { a: shared, b: shared };
100+
101+
const flattedString = stringify(original);
102+
const result = (await parseFlattedAsync(flattedString)) as Record<string, unknown>;
103+
104+
expect(result.a).toEqual(shared);
105+
expect(result.b).toEqual(shared);
106+
// flatted preserves reference identity
107+
expect(result.a).toBe(result.b);
108+
});
109+
110+
it('should correctly parse large payloads', async () => {
111+
// Generate data large enough to exercise chunked streaming
112+
const items: Array<{ id: number; data: string }> = [];
113+
for (let i = 0; i < 5000; i++) {
114+
items.push({ id: i, data: `item-${i}-${'x'.repeat(1200)}` });
115+
}
116+
const original = { resultData: { runData: { Node1: [{ data: items }] } } };
117+
118+
const flattedString = stringify(original);
119+
expect(flattedString.length).toBeGreaterThan(5 * 1024 * 1024);
120+
121+
const syncResult = parse(flattedString);
122+
const asyncResult = await parseFlattedAsync(flattedString);
123+
124+
expect(asyncResult).toEqual(syncResult);
125+
});
126+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { stringify } from 'flatted';
2+
3+
import * as flattedAsync from '../flatted-async';
4+
import { parseFlatted, SIZE_THRESHOLD } from '../parse-flatted';
5+
6+
jest.mock('../flatted-async');
7+
8+
describe('parseFlatted', () => {
9+
const parseFlattedAsyncMock = jest.mocked(flattedAsync.parseFlattedAsync);
10+
11+
beforeEach(() => {
12+
jest.clearAllMocks();
13+
});
14+
15+
it('should use sync flatted.parse for small data', async () => {
16+
const original = { name: 'test' };
17+
const flattedString = stringify(original);
18+
19+
const result = await parseFlatted(flattedString);
20+
21+
expect(result).toEqual(original);
22+
expect(parseFlattedAsyncMock).not.toHaveBeenCalled();
23+
});
24+
25+
it('should use sync flatted.parse for data below the threshold', async () => {
26+
// Valid flatted JSON padded to just under the threshold
27+
const padding = stringify({ data: 'x'.repeat(100) });
28+
expect(padding.length).toBeLessThan(SIZE_THRESHOLD);
29+
30+
await parseFlatted(padding);
31+
32+
expect(parseFlattedAsyncMock).not.toHaveBeenCalled();
33+
});
34+
35+
it('should use parseFlattedAsync for data at or above the threshold', async () => {
36+
const aboveThreshold = '[' + '"x"'.repeat(SIZE_THRESHOLD) + ']';
37+
parseFlattedAsyncMock.mockResolvedValue({ parsed: true });
38+
39+
const result = await parseFlatted(aboveThreshold);
40+
41+
expect(parseFlattedAsyncMock).toHaveBeenCalledWith(aboveThreshold);
42+
expect(result).toEqual({ parsed: true });
43+
});
44+
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* Async flatted JSON parsing using stream-json.
3+
*
4+
* For large execution data, synchronous flatted.parse() blocks the event loop.
5+
* This module provides an async alternative that streams the JSON in 64KB
6+
* chunks, yielding to the event loop between chunks via setImmediate.
7+
*/
8+
import { ensureError } from 'n8n-workflow';
9+
import { Readable } from 'stream';
10+
import { parser } from 'stream-json';
11+
import Asm from 'stream-json/Assembler';
12+
13+
// 64 KB chunks — selected via benchmarks as the sweet spot for event-loop
14+
// responsiveness vs scheduling overhead (p95 lag ~4 ms at 50 MB payload).
15+
const CHUNK_SIZE = 64 * 1024;
16+
17+
//#region Flatted reference resolution (extracted from flatted@3.2.7)
18+
19+
const Primitive = String;
20+
const primitive = 'string';
21+
const object = 'object';
22+
const ignore = {};
23+
const noop = (_: string, value: unknown) => value;
24+
25+
const primitives = (value: unknown) =>
26+
value instanceof Primitive ? Primitive(value as string) : value;
27+
28+
const revive = (
29+
input: unknown[],
30+
parsed: Set<unknown>,
31+
output: Record<string, unknown>,
32+
$: (key: string, value: unknown) => unknown,
33+
): Record<string, unknown> => {
34+
const lazy: Array<{
35+
k: string;
36+
a: [unknown[], Set<unknown>, Record<string, unknown>, typeof $];
37+
}> = [];
38+
const ke = Object.keys(output);
39+
for (let y = 0; y < ke.length; y++) {
40+
const k = ke[y];
41+
const value = output[k];
42+
if (value instanceof Primitive) {
43+
const tmp = (input as unknown as Record<string, unknown>)[value as unknown as string];
44+
if (typeof tmp === object && !parsed.has(tmp)) {
45+
parsed.add(tmp);
46+
output[k] = ignore;
47+
lazy.push({ k, a: [input, parsed, tmp as Record<string, unknown>, $] });
48+
} else {
49+
output[k] = $.call(output, k, tmp);
50+
}
51+
} else if (output[k] !== ignore) {
52+
output[k] = $.call(output, k, value);
53+
}
54+
}
55+
for (let i = 0; i < lazy.length; i++) {
56+
const { k, a } = lazy[i];
57+
output[k] = $.call(output, k, revive(...a));
58+
}
59+
return output;
60+
};
61+
62+
/**
63+
* Resolve flatted references on a pre-parsed array.
64+
* This reconstructs the original object graph including circular references.
65+
*/
66+
function resolveFlatted(rawArray: unknown[]): unknown {
67+
const input = rawArray;
68+
const value = input[0];
69+
const $ = noop;
70+
const tmp =
71+
typeof value === object && value
72+
? revive(input, new Set(), value as Record<string, unknown>, $)
73+
: value;
74+
return $.call({ '': tmp }, '', tmp);
75+
}
76+
77+
//#endregion Flatted reference resolution
78+
79+
/**
80+
* Recursively convert every string value into a String object wrapper.
81+
* This replicates what JSON.parse(text, Primitives) does so that flatted
82+
* can distinguish index references from literal string values.
83+
*/
84+
function applyPrimitivesDeep(value: unknown): unknown {
85+
if (typeof value === primitive) {
86+
return new Primitive(value);
87+
}
88+
if (Array.isArray(value)) {
89+
for (let i = 0; i < value.length; i++) {
90+
value[i] = applyPrimitivesDeep(value[i]);
91+
}
92+
return value;
93+
}
94+
if (typeof value === object && value !== null) {
95+
const ke = Object.keys(value as Record<string, unknown>);
96+
for (let i = 0; i < ke.length; i++) {
97+
const k = ke[i];
98+
(value as Record<string, unknown>)[k] = applyPrimitivesDeep(
99+
(value as Record<string, unknown>)[k],
100+
);
101+
}
102+
return value;
103+
}
104+
return value;
105+
}
106+
107+
/**
108+
* Prepare raw parsed JSON for flatted reference resolution.
109+
* Applies the Primitives transformation then unwraps String objects.
110+
*/
111+
function prepareFlatted(rawParsed: unknown[]): unknown[] {
112+
return (applyPrimitivesDeep(rawParsed) as unknown[]).map(primitives);
113+
}
114+
115+
/**
116+
* Parse a flatted JSON string asynchronously using stream-json.
117+
* Streams the string in 64KB chunks, yielding to the event loop between chunks
118+
* via setImmediate, then resolves flatted references synchronously.
119+
*/
120+
export async function parseFlattedAsync(flattedString: string): Promise<unknown> {
121+
return await new Promise((resolve, reject) => {
122+
let offset = 0;
123+
const readable = new Readable({
124+
read() {
125+
if (offset >= flattedString.length) {
126+
this.push(null);
127+
return;
128+
}
129+
const chunk = flattedString.slice(offset, offset + CHUNK_SIZE);
130+
offset += CHUNK_SIZE;
131+
setImmediate(() => {
132+
this.push(chunk);
133+
});
134+
},
135+
});
136+
137+
const jsonParser = parser();
138+
const asm = Asm.connectTo(jsonParser);
139+
140+
asm.on('done', (asmResult: { current: unknown[] }) => {
141+
try {
142+
const prepared = prepareFlatted(asmResult.current);
143+
resolve(resolveFlatted(prepared));
144+
} catch (e) {
145+
reject(ensureError(e));
146+
}
147+
});
148+
149+
jsonParser.on('error', reject);
150+
readable.on('error', reject);
151+
152+
readable.pipe(jsonParser);
153+
});
154+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { parse } from 'flatted';
2+
3+
import { parseFlattedAsync } from './flatted-async';
4+
5+
// 1 MB — below this, sync parse is fast enough
6+
export const SIZE_THRESHOLD = 1 * 1024 * 1024;
7+
8+
/**
9+
* Parse a flatted JSON string, using async streaming for large payloads
10+
* and sync parsing for small ones.
11+
*
12+
* @param data - The flatted JSON string to parse
13+
* @returns The deserialized object
14+
*/
15+
export async function parseFlatted(data: string): Promise<unknown> {
16+
if (data.length < SIZE_THRESHOLD) {
17+
return parse(data);
18+
}
19+
return await parseFlattedAsync(data);
20+
}

0 commit comments

Comments
 (0)