Skip to content

Commit 2cff67e

Browse files
authored
fix: customer aspect cannot add Tags if a BucketNotifications construct is present (#33979)
The title refers to a specific case in which we discovered this, but the problem was more general. Now that Aspects have a priority, we want to execute based on priority as the primary sort key. To make sure we don't make mistakes, the new AspectsV2 invocation (enabled with flag `@aws-cdk/core.aspectStabilization: true`) throws an error if it detects a violation of the strict priority execution order. Because of the moment aspects are looked up and sorted by priority, this currently goes wrong if: - Aspects with a low and high priority order (say, 100 and 700) are already present in the construct tree - The invocation of the low-priority Aspect adds another Aspect with a priority in between 100 and 700. The reason is that we used to look up the aspects before iterating, and iterate over them. At that point we have the list `[100, 700]`, and we invoke both. Then, when we go around again, the new list is `[100, 500, 700]` with the `500` Aspect having not been invoked yet -- but also the Aspect with priority `700` already having been invoked. If we invoke `500` at that point, we would have inverted the priority order, so we fail instead. That is undesirable: what we *should* have done is noticed that `500` got inserted between `100` and `700` and do that next. Currently achieve that with the following: - We associate a global revision number with the entire construct tree, at the root. - Every time an aspect gets added, we increment that number. - This allows us to detect Aspect order cache validations, so we can abort what we're doing and recalculate the tree. The system of inserting and recalculating could probably all be made more efficient, but aspects adding aspects should be relatively rare so until we have evidence that it is too expensive, this will do for now. In the specific case reported for this, a customer Aspect with priority 10 adds a Tag Aspect (priority 200) while the `BucketNotification` construct also adds an Aspect on itself with priority 500. The mere presence of this `BucketNotification` construct with its self-added Aspect prevents any Aspect from adding tags. Closes #33943. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 929ab3a commit 2cff67e

File tree

3 files changed

+135
-23
lines changed

3 files changed

+135
-23
lines changed

packages/aws-cdk-lib/core/lib/aspect.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export class Aspects {
9090
return;
9191
}
9292
this._appliedAspects.push(newApplication);
93+
bumpAspectTreeRevision(this._scope);
9394
}
9495

9596
/**
@@ -109,6 +110,33 @@ export class Aspects {
109110
}
110111
}
111112

113+
/**
114+
* Bump the tree revision for the tree containing the given construct.
115+
*
116+
* Module-local function since I don't want to open up the door for *anyone* calling this.
117+
*/
118+
function bumpAspectTreeRevision(construct: IConstruct) {
119+
const root = construct.node.root;
120+
const rev = (root as any)[TREE_REVISION_SYM] ?? 0;
121+
(root as any)[TREE_REVISION_SYM] = rev + 1;
122+
}
123+
124+
/**
125+
* Return the aspect revision of the tree that the given construct is part of
126+
*
127+
* The revision number is changed every time an aspect is added to the tree.
128+
*
129+
* Instead of returning the version, returns a function that returns the
130+
* version: we can cache the root lookup inside the function; this saves
131+
* crawling the tree on every read, which is frequent.
132+
*
133+
* @internal
134+
*/
135+
export function _aspectTreeRevisionReader(construct: IConstruct): () => number {
136+
const root = construct.node.root;
137+
return () => (root as any)[TREE_REVISION_SYM] ?? 0;
138+
}
139+
112140
/**
113141
* Object respresenting an Aspect application. Stores the Aspect, the pointer to the construct it was applied
114142
* to, and the priority value of that Aspect.
@@ -153,5 +181,10 @@ export class AspectApplication {
153181
throw new Error('Priority must be a non-negative number');
154182
}
155183
this._priority = priority;
184+
185+
// This invalidates any cached ordering.
186+
bumpAspectTreeRevision(this.construct);
156187
}
157188
}
189+
190+
const TREE_REVISION_SYM = Symbol.for('@aws-cdk/core.Aspects.treeRevision');

packages/aws-cdk-lib/core/lib/private/synthesis.ts

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { CloudAssembly } from '../../../cx-api';
88
import * as cxapi from '../../../cx-api';
99
import { Annotations } from '../annotations';
1010
import { App } from '../app';
11-
import { AspectApplication, AspectPriority, Aspects } from '../aspect';
11+
import { _aspectTreeRevisionReader, AspectApplication, AspectPriority, Aspects } from '../aspect';
1212
import { FileSystem } from '../fs';
1313
import { Stack } from '../stack';
1414
import { ISynthesisSession } from '../stack-synthesizers/types';
@@ -37,7 +37,7 @@ export function synthesize(root: IConstruct, options: SynthesisOptions = { }): c
3737
// we start by calling "synth" on all nested assemblies (which will take care of all their children)
3838
synthNestedAssemblies(root, options);
3939

40-
if (options.aspectStabilization == true) {
40+
if (options.aspectStabilization) {
4141
invokeAspectsV2(root);
4242
} else {
4343
invokeAspects(root);
@@ -274,32 +274,33 @@ function invokeAspects(root: IConstruct) {
274274
* than the most recently invoked Aspect on that node.
275275
*/
276276
function invokeAspectsV2(root: IConstruct) {
277-
const invokedByPath: { [nodePath: string]: AspectApplication[] } = { };
277+
const invokedByPath = new Map<string, AspectApplication[]>();
278278

279279
recurse(root, []);
280280

281281
for (let i = 0; i <= 100; i++) {
282282
const didAnythingToTree = recurse(root, []);
283283

284-
if (!didAnythingToTree) {
284+
// Keep on invoking until nothing gets invoked anymore
285+
if (didAnythingToTree === 'nothing') {
285286
return;
286287
}
287288
}
288289

289290
throw new Error('We have detected a possible infinite loop while invoking Aspects. Please check your Aspects and verify there is no configuration that would cause infinite Aspect or Node creation.');
290291

291-
function recurse(construct: IConstruct, inheritedAspects: AspectApplication[]): boolean {
292+
function recurse(construct: IConstruct, inheritedAspects: AspectApplication[]): 'invoked' | 'abort-recursion' | 'nothing' {
292293
const node = construct.node;
293294

294-
let didSomething = false;
295-
296-
let localAspects = getAspectApplications(construct);
297-
const allAspectsHere = sortAspectsByPriority(inheritedAspects, localAspects);
295+
let ret: ReturnType<typeof recurse> = 'nothing';
296+
const currentAspectTreeRevision = _aspectTreeRevisionReader(construct);
297+
const versionAtStart = currentAspectTreeRevision();
298298

299+
const allAspectsHere = sortAspectsByPriority(inheritedAspects, getAspectApplications(construct));
299300
for (const aspectApplication of allAspectsHere) {
300-
let invoked = invokedByPath[node.path];
301+
let invoked = invokedByPath.get(node.path);
301302
if (!invoked) {
302-
invoked = invokedByPath[node.path] = [];
303+
invokedByPath.set(node.path, invoked = []);
303304
}
304305

305306
if (invoked.some(invokedApp => invokedApp.aspect === aspectApplication.aspect)) {
@@ -310,25 +311,38 @@ function invokeAspectsV2(root: IConstruct) {
310311
const lastInvokedAspect = invoked[invoked.length - 1];
311312
if (lastInvokedAspect && lastInvokedAspect.priority > aspectApplication.priority) {
312313
throw new Error(
313-
`Cannot invoke Aspect ${aspectApplication.aspect.constructor.name} with priority ${aspectApplication.priority} on node ${node.path}: an Aspect ${lastInvokedAspect.aspect.constructor.name} with a lower priority (${lastInvokedAspect.priority}) was already invoked on this node.`,
314+
`Cannot invoke Aspect ${aspectApplication.aspect.constructor.name} with priority ${aspectApplication.priority} on node ${node.path}: an Aspect ${lastInvokedAspect.aspect.constructor.name} with a lower priority (added at ${lastInvokedAspect.construct.node.path} with priority ${lastInvokedAspect.priority}) was already invoked on this node.`,
314315
);
315316
}
316317

317318
aspectApplication.aspect.visit(construct);
318319

319-
didSomething = true;
320+
ret = 'invoked';
320321

321322
// mark as invoked for this node
322323
invoked.push(aspectApplication);
324+
325+
// If this aspect added another aspect, we need to reconsider everything;
326+
// it might have added an aspect above us and we need to restart the
327+
// entire tree. This could probably be made more efficient, but restarting
328+
// the tree from the top currently does it as well.
329+
if (currentAspectTreeRevision() !== versionAtStart) {
330+
return 'abort-recursion';
331+
}
323332
}
324333

325-
let childDidSomething = false;
326334
for (const child of construct.node.children) {
327335
if (!Stage.isStage(child)) {
328-
childDidSomething = recurse(child, allAspectsHere) || childDidSomething;
336+
const childDidSomething = recurse(child, allAspectsHere);
337+
ret = childDidSomething !== 'nothing' ? childDidSomething : ret;
338+
339+
if (ret === 'abort-recursion') {
340+
break;
341+
}
329342
}
330343
}
331-
return didSomething || childDidSomething;
344+
345+
return ret;
332346
}
333347
}
334348

packages/aws-cdk-lib/core/test/aspect.test.ts

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { Construct, IConstruct } from 'constructs';
2-
import { getWarnings } from './util';
32
import { Template } from '../../assertions';
43
import { Bucket, CfnBucket } from '../../aws-s3';
54
import * as cxschema from '../../cloud-assembly-schema';
65
import { App, CfnResource, Stack, Tag, Tags } from '../lib';
7-
import { IAspect, Aspects, AspectPriority } from '../lib/aspect';
6+
import { IAspect, Aspects, AspectPriority, _aspectTreeRevisionReader } from '../lib/aspect';
87
import { MissingRemovalPolicies, RemovalPolicies } from '../lib/removal-policies';
98
import { RemovalPolicy } from '../lib/removal-policy';
109

@@ -298,7 +297,7 @@ describe('aspect', () => {
298297

299298
expect(() => {
300299
app.synth();
301-
}).toThrow('Cannot invoke Aspect Tag with priority 0 on node My-Stack: an Aspect Tag with a lower priority (10) was already invoked on this node.');
300+
}).toThrow('an Aspect Tag with a lower priority');
302301
});
303302

304303
test('Infinitely recursing Aspect is caught with error', () => {
@@ -338,14 +337,13 @@ describe('aspect', () => {
338337
Aspects.of(root).add(new Tag('AspectA', 'Visited'));
339338

340339
// "Monkey patching" - Override `applied` to simulate its absence
341-
Object.defineProperty(Aspects.prototype, 'applied', {
342-
value: undefined,
343-
configurable: true,
344-
});
340+
const appliedGetter = jest.spyOn(Aspects.prototype, 'applied', 'get').mockReturnValue(undefined as any);
345341

346342
expect(() => {
347343
app.synth();
348344
}).not.toThrow();
345+
346+
appliedGetter.mockRestore();
349347
});
350348

351349
test('RemovalPolicy: higher priority wins', () => {
@@ -456,4 +454,71 @@ describe('aspect', () => {
456454
DeletionPolicy: 'Retain',
457455
});
458456
});
457+
458+
test.each([
459+
'on self',
460+
'on parent',
461+
] as const)('aspect can insert another aspect %s in between other ones', (addWhere) => {
462+
// Test that between two pre-existing aspects with LOW and HIGH priorities,
463+
// the LOW aspect can add one in the middle, when stabilization is enabled.
464+
//
465+
// With stabilization (Aspects V2), we are stricter about the ordering and we would
466+
// throw if something doesn't work.
467+
468+
// GIVEN
469+
const app = new App();
470+
const root = new MyConstruct(app, 'Root');
471+
const aspectTarget = new MyConstruct(root, 'MyConstruct');
472+
473+
// WHEN
474+
// - Aspect with prio 100 adds an Aspect with prio 500
475+
// - An Aspect with prio 700 also exists
476+
// We should execute all and in the right order.
477+
const executed = new Array<string>();
478+
479+
class TestAspect implements IAspect {
480+
constructor(private readonly name: string, private readonly block?: () => void) {
481+
}
482+
483+
visit(node: IConstruct): void {
484+
// Only do something for the target node
485+
if (node.node.path === 'Root/MyConstruct') {
486+
executed.push(this.name);
487+
this.block?.();
488+
}
489+
}
490+
}
491+
492+
Aspects.of(aspectTarget).add(new TestAspect('one', () => {
493+
// We either add the new aspect on ourselves, or on an ancestor.
494+
//
495+
// In either case, it should execute next, before the 700 executes.
496+
const addHere = addWhere === 'on self' ? aspectTarget : root;
497+
498+
Aspects.of(addHere).add(new TestAspect('two'), { priority: 500 });
499+
}), { priority: 100 });
500+
Aspects.of(aspectTarget).add(new TestAspect('three'), { priority: 700 });
501+
502+
// THEN: should not throw and execute in the right order
503+
app.synth({ aspectStabilization: true });
504+
505+
expect(executed).toEqual(['one', 'two', 'three']);
506+
});
507+
508+
test('changing an aspect\'s priority invalidates the aspect tree', () => {
509+
const app = new App();
510+
const ctr = new MyConstruct(app, 'Root');
511+
512+
// GIVEN
513+
const aspect = new MyAspect();
514+
Aspects.of(ctr).add(aspect);
515+
const currentRevision = _aspectTreeRevisionReader(ctr);
516+
const initialRevision = currentRevision();
517+
518+
// WHEN
519+
Aspects.of(ctr).applied[0].priority = 500;
520+
521+
// THEN
522+
expect(currentRevision()).not.toEqual(initialRevision);
523+
});
459524
});

0 commit comments

Comments
 (0)