Skip to content

Commit 9e45095

Browse files
authored
fix(core): cdk deploy stops early if 2 stacks with a dependency between them share an asset (#25719)
Fixes cdk deploy not completing for some apps by removing cycles from core's worker graph between asset publishing steps & stacks. These cycles may be introduced when there dependencies between stacks and these stacks share assets with the same contents. Additionally fixes a bug in the worker graph's error handling for cycles. Closes #25714 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 49dcc05 commit 9e45095

File tree

4 files changed

+115
-13
lines changed

4 files changed

+115
-13
lines changed

packages/aws-cdk/lib/util/work-graph-builder.ts

+26-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ export class WorkGraphBuilder {
7575
// This is purely cosmetic: if we don't do this, the progress printing of asset publishing
7676
// is going to interfere with the progress bar of the stack deployment. We could remove this
7777
// for overall faster deployments if we ever have a better method of progress displaying.
78-
...this.getDepIds(parentStack.dependencies),
78+
// Note: this may introduce a cycle if one of the parent's dependencies is another stack that
79+
// depends on this asset. To workaround this we remove these cycles once all nodes have
80+
// been added to the graph.
81+
...this.getDepIds(parentStack.dependencies.filter(cxapi.CloudFormationStackArtifact.isCloudFormationStackArtifact)),
7982
]),
8083
parentStack,
8184
assetManifestArtifact: assetArtifact,
@@ -114,6 +117,10 @@ export class WorkGraphBuilder {
114117
}
115118

116119
this.graph.removeUnavailableDependencies();
120+
121+
// Remove any potentially introduced cycles between asset publishing and the stacks that depend on them.
122+
this.removeStackPublishCycles();
123+
117124
return this.graph;
118125
}
119126

@@ -129,6 +136,24 @@ export class WorkGraphBuilder {
129136
}
130137
return ids;
131138
}
139+
140+
private removeStackPublishCycles() {
141+
const stacks = this.graph.nodesOfType('stack');
142+
for (const stack of stacks) {
143+
for (const dep of stack.dependencies) {
144+
const node = this.graph.nodes[dep];
145+
146+
if (!node || node.type !== 'asset-publish' || !node.dependencies.has(stack.id)) {
147+
continue;
148+
}
149+
150+
// Delete the dependency from the asset-publish onto the stack.
151+
// The publish -> stack dependencies are purely cosmetic to prevent publish output
152+
// from interfering with the progress bar of the stack deployment.
153+
node.dependencies.delete(stack.id);
154+
}
155+
}
156+
}
132157
}
133158

134159
function stacksFromAssets(artifacts: cxapi.CloudArtifact[]) {

packages/aws-cdk/lib/util/work-graph.ts

+13-11
Original file line numberDiff line numberDiff line change
@@ -165,17 +165,19 @@ export class WorkGraph {
165165
function startOne(x: WorkNode) {
166166
x.deploymentState = DeploymentState.DEPLOYING;
167167
active[x.type]++;
168-
void fn(x).then(() => {
169-
active[x.type]--;
170-
graph.deployed(x);
171-
start();
172-
}).catch((err) => {
173-
active[x.type]--;
174-
// By recording the failure immediately as the queued task exits, we prevent the next
175-
// queued task from starting.
176-
graph.failed(x, err);
177-
start();
178-
});
168+
void fn(x)
169+
.finally(() => {
170+
active[x.type]--;
171+
})
172+
.then(() => {
173+
graph.deployed(x);
174+
start();
175+
}).catch((err) => {
176+
// By recording the failure immediately as the queued task exits, we prevent the next
177+
// queued task from starting.
178+
graph.failed(x, err);
179+
start();
180+
});
179181
}
180182
});
181183
}

packages/aws-cdk/test/work-graph-builder.test.ts

+44
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,50 @@ test('dependencies on unselected artifacts are silently ignored', async () => {
109109
}));
110110
});
111111

112+
test('assets with shared contents between dependant stacks', async () => {
113+
const files = {
114+
// Referencing an existing file on disk is important here.
115+
// It means these two assets will have the same AssetManifest
116+
// and the graph will merge the two into a single asset.
117+
'work-graph-builder.test.js': {
118+
source: { path: __dirname },
119+
destinations: {
120+
D1: { bucketName: 'bucket', objectKey: 'key' },
121+
},
122+
},
123+
};
124+
125+
addStack(rootBuilder, 'StackA', {
126+
environment: 'aws://11111/us-east-1',
127+
dependencies: ['StackA.assets'],
128+
});
129+
addAssets(rootBuilder, 'StackA.assets', { files });
130+
131+
addStack(rootBuilder, 'StackB', {
132+
environment: 'aws://11111/us-east-1',
133+
dependencies: ['StackB.assets', 'StackA'],
134+
});
135+
addAssets(rootBuilder, 'StackB.assets', { files });
136+
137+
const assembly = rootBuilder.buildAssembly();
138+
139+
const traversal: string[] = [];
140+
const graph = new WorkGraphBuilder(true).build(assembly.artifacts);
141+
await graph.doParallel(1, {
142+
deployStack: async (node) => { traversal.push(node.id); },
143+
buildAsset: async (node) => { traversal.push(node.id); },
144+
publishAsset: async (node) => { traversal.push(node.id); },
145+
});
146+
147+
expect(traversal).toHaveLength(4); // 1 asset build, 1 asset publish, 2 stacks
148+
expect(traversal).toEqual([
149+
'work-graph-builder.test.js:D1-build',
150+
'work-graph-builder.test.js:D1-publish',
151+
'StackA',
152+
'StackB',
153+
]);
154+
});
155+
112156
/**
113157
* Write an asset manifest file and add it to the assembly builder
114158
*/

packages/aws-cdk/test/work-graph.test.ts

+32-1
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ describe('WorkGraph', () => {
249249
expect(actionedAssets).toEqual(['a-build', 'a-publish', 'A']);
250250
});
251251

252-
// Failure
252+
// Failure Concurrency
253253
test.each([
254254
// Concurrency 1
255255
{ scenario: 'A (error)', concurrency: 1, toDeploy: createArtifacts([{ id: 'A', type: 'stack', displayName: 'A' }]), expectedError: 'A', expectedStacks: [] },
@@ -320,6 +320,37 @@ describe('WorkGraph', () => {
320320

321321
expect(actionedAssets).toStrictEqual(expectedStacks);
322322
});
323+
324+
// Failure Graph Circular Dependencies
325+
test.each([
326+
{
327+
scenario: 'A -> A',
328+
toDeploy: createArtifacts([
329+
{ id: 'A', type: 'stack', stackDependencies: ['A'] },
330+
]),
331+
},
332+
{
333+
scenario: 'A -> B, B -> A',
334+
toDeploy: createArtifacts([
335+
{ id: 'A', type: 'stack', stackDependencies: ['B'] },
336+
{ id: 'B', type: 'stack', stackDependencies: ['A'] },
337+
]),
338+
},
339+
{
340+
scenario: 'A, B -> C, C -> D, D -> B',
341+
toDeploy: createArtifacts([
342+
{ id: 'A', type: 'stack' }, // Add a node to visit first so the infinite loop occurs deeper in the traversal callstack.
343+
{ id: 'B', type: 'stack', stackDependencies: ['C'] },
344+
{ id: 'C', type: 'stack', stackDependencies: ['D'] },
345+
{ id: 'D', type: 'stack', stackDependencies: ['B'] },
346+
]),
347+
},
348+
])('Failure - Graph Circular Dependencies - $scenario', async ({ toDeploy }) => {
349+
const graph = new WorkGraph();
350+
addTestArtifactsToGraph(toDeploy, graph);
351+
352+
await expect(graph.doParallel(1, callbacks)).rejects.toThrowError(/Unable to make progress anymore/);
353+
});
323354
});
324355

325356
interface TestArtifact {

0 commit comments

Comments
 (0)