Skip to content

Commit ff2f4af

Browse files
authored
fix: synthesis fails if tree.json exceeds 512MB (#34478)
In large applications with a lot of constructs, `tree.json` can grow to exceed 512MB, which means it can't be serialized anymore. In this PR, introduce the concept of a "subtree reference". Once a construct tree grows to exceed a fixed number of nodes we write subtrees to individual other files, and put a reference to those files into the original tree. The number of nodes can be configured with the context value `@aws-cdk/core.TreeMetadata:maxNodes`, and defaults to 500,000 (that assumes an average size of 1kB per node, which is an overestimate for safety. If we find that this number is too high in practice we may still lower it in the future). Fixes #27261. For unupdated consumers, there is graceful degradation here: the parts of the tree they will be able to see are cut off at certain tree depths, but only very large applications would be affected by this. Tree data consumers can be updated gradually to deal with these references. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 295a547 commit ff2f4af

File tree

5 files changed

+493
-33
lines changed

5 files changed

+493
-33
lines changed

packages/@aws-cdk-testing/framework-integ/test/core/test/tree-metadata.test.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as path from 'path';
66
import { Construct } from 'constructs';
77
import * as cxschema from 'aws-cdk-lib/cloud-assembly-schema';
88
import { App, CfnParameter, CfnResource, Lazy, Stack, TreeInspector } from 'aws-cdk-lib';
9+
import { TreeFile } from 'aws-cdk-lib/core/lib/private/tree-metadata';
910

1011
abstract class AbstractCfnResource extends CfnResource {
1112
constructor(scope: Construct, id: string) {
@@ -162,7 +163,9 @@ describe('tree metadata', () => {
162163
const treeArtifact = assembly.tree();
163164
expect(treeArtifact).toBeDefined();
164165

165-
expect(readJson(assembly.directory, treeArtifact!.file)).toEqual({
166+
const treeJson = readJson(assembly.directory, treeArtifact!.file);
167+
168+
expect(treeJson).toEqual({
166169
version: 'tree-0.1',
167170
tree: expect.objectContaining({
168171
children: expect.objectContaining({
@@ -185,6 +188,91 @@ describe('tree metadata', () => {
185188
});
186189
});
187190

191+
/**
192+
* Check that we can limit ourselves to a given tree file size
193+
*
194+
* We can't try the full 512MB because the test process will run out of memory
195+
* before synthing such a large tree.
196+
*/
197+
test('tree.json can be split over multiple files', () => {
198+
const MAX_NODES = 1_000;
199+
const app = new App({
200+
context: {
201+
'@aws-cdk/core.TreeMetadata:maxNodes': MAX_NODES,
202+
},
203+
analyticsReporting: false,
204+
});
205+
206+
// GIVEN
207+
const buildStart = Date.now();
208+
const addedNodes = recurseBuild(app, 4, 4);
209+
// eslint-disable-next-line no-console
210+
console.log('Built tree in', Date.now() - buildStart, 'ms');
211+
212+
// WHEN
213+
const synthStart = Date.now();
214+
const assembly = app.synth();
215+
// eslint-disable-next-line no-console
216+
console.log('Synthed tree in', Date.now() - synthStart, 'ms');
217+
try {
218+
const treeArtifact = assembly.tree();
219+
expect(treeArtifact).toBeDefined();
220+
221+
// THEN - does not explode, and file sizes are correctly limited
222+
const sizes: Record<string, number> = {};
223+
recurseVisit(assembly.directory, treeArtifact!.file, sizes);
224+
225+
for (const size of Object.values(sizes)) {
226+
expect(size).toBeLessThanOrEqual(MAX_NODES);
227+
}
228+
229+
expect(Object.keys(sizes).length).toBeGreaterThan(1);
230+
231+
const foundNodes = sum(Object.values(sizes));
232+
expect(foundNodes).toEqual(addedNodes + 2); // App, Tree
233+
} finally {
234+
fs.rmSync(assembly.directory, { force: true, recursive: true });
235+
}
236+
237+
function recurseBuild(scope: Construct, n: number, depth: number) {
238+
if (depth === 0) {
239+
const resourceCount = 450;
240+
const stack = new Stack(scope, 'SomeStack');
241+
for (let i = 0; i < resourceCount; i++) {
242+
new CfnResource(stack, `Resource${i}`, { type: 'Aws::Some::Resource' });
243+
}
244+
return resourceCount + 3; // Also count Stack, BootstrapVersion, CheckBootstrapVersion
245+
}
246+
247+
let ret = 0;
248+
for (let i = 0; i < n; i++) {
249+
const parent = new Construct(scope, `Construct${i}`);
250+
ret += 1;
251+
ret += recurseBuild(parent, n, depth - 1);
252+
}
253+
return ret;
254+
}
255+
256+
function recurseVisit(directory: string, fileName: string, files: Record<string, number>) {
257+
let nodes = 0;
258+
const treeJson: TreeFile = readJson(directory, fileName);
259+
rec(treeJson.tree);
260+
files[fileName] = nodes;
261+
262+
function rec(x: TreeFile['tree']) {
263+
if (isSubtreeReference(x)) {
264+
// We'll count this node as part of our visit to the "real" node
265+
recurseVisit(directory, x.fileName, files);
266+
} else {
267+
nodes += 1;
268+
for (const child of Object.values(x.children ?? {})) {
269+
rec(child);
270+
}
271+
}
272+
}
273+
}
274+
});
275+
188276
test('token resolution & cfn parameter', () => {
189277
const app = new App();
190278
const stack = new Stack(app, 'mystack');
@@ -396,3 +484,15 @@ describe('tree metadata', () => {
396484
function readJson(outdir: string, file: string) {
397485
return JSON.parse(fs.readFileSync(path.join(outdir, file), 'utf-8'));
398486
}
487+
488+
function isSubtreeReference(x: TreeFile['tree']): x is Extract<TreeFile['tree'], { fileName: string }> {
489+
return !!(x as any).fileName;
490+
}
491+
492+
function sum(xs: number[]) {
493+
let ret = 0;
494+
for (const x of xs) {
495+
ret += x;
496+
}
497+
return ret;
498+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { IConstruct } from 'constructs';
2+
import { LinkedQueue } from './linked-queue';
3+
4+
/**
5+
* Breadth-first iterator over the construct tree
6+
*/
7+
export function* iterateBfs(root: IConstruct) {
8+
// Use a specialized queue data structure. Using `Array.shift()`
9+
// has a huge performance penalty (difference on the order of
10+
// ~50ms vs ~1s to iterate a large construct tree)
11+
const queue = new LinkedQueue<{ construct: IConstruct; parent: IConstruct | undefined }>([{ construct: root, parent: undefined }]);
12+
13+
let next = queue.shift();
14+
while (next) {
15+
for (const child of next.construct.node.children) {
16+
queue.push({ construct: child, parent: next.construct });
17+
}
18+
yield next;
19+
20+
next = queue.shift();
21+
}
22+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* A queue that is faster than an array at large throughput
3+
*/
4+
export class LinkedQueue<A> {
5+
private head?: Node<A>;
6+
private last?: Node<A>;
7+
8+
constructor(items?: Iterable<A>) {
9+
if (items) {
10+
for (const x of items) {
11+
this.push(x);
12+
}
13+
}
14+
}
15+
16+
public push(value: A) {
17+
const node: Node<A> = { value };
18+
if (this.head && this.last) {
19+
this.last.next = node;
20+
this.last = node;
21+
} else {
22+
this.head = node;
23+
this.last = node;
24+
}
25+
}
26+
27+
public shift(): A | undefined {
28+
if (!this.head) {
29+
return undefined;
30+
}
31+
const ret = this.head.value;
32+
33+
this.head = this.head.next;
34+
if (!this.head) {
35+
this.last = undefined;
36+
}
37+
38+
return ret;
39+
}
40+
}
41+
42+
interface Node<A> {
43+
value: A;
44+
next?: Node<A>;
45+
}

0 commit comments

Comments
 (0)