Skip to content

Commit 5f4bfa7

Browse files
authored
fix!: cloud assemblies are not locked (#344)
Cloud Assemblies are supposed to be read-locked as they are being used, so that concurrent writers into the same directories do not trample on the `cdk.out` directory as we are deploying it. This is being done with the class `RWLock` which represents a read/write lock. A lock is associated to a directory, which can have either at most one writer or multiple readers. This PR is a synthesis of #335 and #339. Closes aws/aws-cdk#32964, closes #335, closes #339. # Design Because we need to add the concept of a lock to a Cloud Assembly, and we want that resource to be disposable, we introduce the class `IReadableCloudAssembly`. This interface models both a cloud assembly and a dispose function which releases the lock and optionally cleans up the backing directory of the cloud assembly. Cloud Assembly Sources (`ICloudAssemblySource.produce()`) now returns a `IReadableCloudAssembly` with a lock associated. The factory functions start off by acquiring write locks on the backing directory during synthesis, which are converted to read locks after a successful synthesis. * All toolkit functions take an `ICloudAssemblySource`, and dispose of the produced `IReadableCloudAssembly` after use; * Except `synth()` now returns a `CachedCloudAssembly`. That class implements both `IReadableCloudAssembly` (as in, it is a cloud assembly that is locked and ready to be read), as well as `ICloudAssemblySource` so that it can be passed into other toolkit functions. The `CachedCloudAssembly.produce()` returns borrowed versions of `IReadableCloudAssembly` with dispose functions that don't do anything: only the top-level dispose actually cleans anything up. This is so that if the result of `synth()` is passed into (multiple) other toolkit functions, their dispose calls don't accidentally release the lock. This could have been done with reference counting, but is currently being done by giving out a "borrowed" Cloud Assembly which doesn't clean up at all. * The locking itself is managed by `ExecutionEnvironment`; this is now a disposable object which will hold a lock that either gets cleaned up automatically with the object, or get converted to a reader lock when the `ExecutionEnvironment` is explicitly marked as having successfully completed. That same change now allows us to automatically clean up temporary directories if synthesis fails, or when the CloudAssembly in them is disposed of. # Supporting changes Supporting changes in this PR, necessary to make the above work: - `StackAssembly`, which is a helper class to query a Cloud Assembly, used to be an `ICloudAssemblySource` but it no longer is. That functionality isn't used anywhere. - Rename `CachedCloudAssemblySource` to `CachedCloudAssembly` and make `synth()` return this concretely. It makes more sense to think of this class as a Cloud Assembly (that happens to be usable as a source), than the reverse. - Locks can now be released more than once; calls after the first won't do anything. This is to prevent an `_unlock()` followed by a `dispose()` from doing something unexpected, and it also shouldn't fail. - Rename `ILock` => `IReadLock` to contrast it more clearly with an `IWriteLock`. - Remove `IdentityCloudAssemblySource`, since it fills much the same role as `CachedCloudAssembly`. - `ExecutionEnvironment` is now disposable, and its sync constructor has been changed to an async factory function. - A lot of tests that called `cx.produce()` directory now have to use an `await using` statement to make sure the read locks are cleaned up, otherwise they get added to the GitHub repo. # New tests for new contracts - Cloud Assemblies are properly disposed by all toolkit methods that take a CloudAssemblySource - The return value of `toolkit.synth()` can be passed to other toolkit methods, but its contents are only disposed when the object is explicitly disposed, not when it's passed to toolkit methods. - When Cloud Assembly producers fail, they should leave the directory unlocked if given, or no directory at all if they are temporary. # Breaking change BREAKING CHANGE: this change changes the return type of `toolkit.synth()`: it no longer returns an arbitrary Assembly Source (by interface), but a specific Assembly, by class, that can also be used as a source. The return type of `ICloudAssemblySource.produce()` has been changed to `IReadableCloudAssembly`. This will only affect consumers with custom implementations of that interface, the factory function APIs are unchanged. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent 7b0973b commit 5f4bfa7

33 files changed

+765
-180
lines changed

packages/@aws-cdk/tmp-toolkit-helpers/src/api/rwlock.ts

+34-12
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,24 @@ export class RWLock {
2929
*
3030
* No other readers or writers must exist for the given directory.
3131
*/
32-
public async acquireWrite(): Promise<IWriterLock> {
32+
public async acquireWrite(): Promise<IWriteLock> {
3333
await this.assertNoOtherWriters();
3434

35-
const readers = await this.currentReaders();
35+
const readers = await this._currentReaders();
3636
if (readers.length > 0) {
3737
throw new ToolkitError(`Other CLIs (PID=${readers}) are currently reading from ${this.directory}. Invoke the CLI in sequence, or use '--output' to synth into different directories.`);
3838
}
3939

4040
await writeFileAtomic(this.writerFile, this.pidString);
4141

42+
let released = false;
4243
return {
4344
release: async () => {
44-
await deleteFile(this.writerFile);
45+
// Releasing needs a flag, otherwise we might delete a file that some other lock has created in the mean time.
46+
if (!released) {
47+
await deleteFile(this.writerFile);
48+
released = true;
49+
}
4550
},
4651
convertToReaderLock: async () => {
4752
// Acquire the read lock before releasing the write lock. Slightly less
@@ -58,7 +63,7 @@ export class RWLock {
5863
*
5964
* Will fail if there are any writers.
6065
*/
61-
public async acquireRead(): Promise<ILock> {
66+
public async acquireRead(): Promise<IReadLock> {
6267
await this.assertNoOtherWriters();
6368
return this.doAcquireRead();
6469
}
@@ -77,27 +82,37 @@ export class RWLock {
7782
/**
7883
* Do the actual acquiring of a read lock.
7984
*/
80-
private async doAcquireRead(): Promise<ILock> {
85+
private async doAcquireRead(): Promise<IReadLock> {
8186
const readerFile = this.readerFile();
8287
await writeFileAtomic(readerFile, this.pidString);
88+
89+
let released = false;
8390
return {
8491
release: async () => {
85-
await deleteFile(readerFile);
92+
// Releasing needs a flag, otherwise we might delete a file that some other lock has created in the mean time.
93+
if (!released) {
94+
await deleteFile(readerFile);
95+
released = true;
96+
}
8697
},
8798
};
8899
}
89100

90101
private async assertNoOtherWriters() {
91-
const writer = await this.currentWriter();
102+
const writer = await this._currentWriter();
92103
if (writer) {
93104
throw new ToolkitError(`Another CLI (PID=${writer}) is currently synthing to ${this.directory}. Invoke the CLI in sequence, or use '--output' to synth into different directories.`);
94105
}
95106
}
96107

97108
/**
98109
* Check the current writer (if any)
110+
*
111+
* Publicly accessible for testing purposes. Do not use.
112+
*
113+
* @internal
99114
*/
100-
private async currentWriter(): Promise<number | undefined> {
115+
public async _currentWriter(): Promise<number | undefined> {
101116
const contents = await readFileIfExists(this.writerFile);
102117
if (!contents) {
103118
return undefined;
@@ -115,8 +130,12 @@ export class RWLock {
115130

116131
/**
117132
* Check the current readers (if any)
133+
*
134+
* Publicly accessible for testing purposes. Do not use.
135+
*
136+
* @internal
118137
*/
119-
private async currentReaders(): Promise<number[]> {
138+
public async _currentReaders(): Promise<number[]> {
120139
const re = /^read\.([^.]+)\.[^.]+\.lock$/;
121140
const ret = new Array<number>();
122141

@@ -151,18 +170,21 @@ export class RWLock {
151170
/**
152171
* An acquired lock
153172
*/
154-
export interface ILock {
173+
export interface IReadLock {
174+
/**
175+
* Release the lock. Can be called more than once.
176+
*/
155177
release(): Promise<void>;
156178
}
157179

158180
/**
159181
* An acquired writer lock
160182
*/
161-
export interface IWriterLock extends ILock {
183+
export interface IWriteLock extends IReadLock {
162184
/**
163185
* Convert the writer lock to a reader lock
164186
*/
165-
convertToReaderLock(): Promise<ILock>;
187+
convertToReaderLock(): Promise<IReadLock>;
166188
}
167189

168190
/* c8 ignore start */ // code paths are unpredictable

packages/@aws-cdk/toolkit-lib/lib/actions/bootstrap/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export class BootstrapEnvironments {
2525
static fromCloudAssemblySource(cx: ICloudAssemblySource): BootstrapEnvironments {
2626
return new BootstrapEnvironments(async (ioHost: IIoHost) => {
2727
const ioHelper = asIoHelper(ioHost, 'bootstrap');
28-
const assembly = await assemblyFromSource(ioHelper, cx);
28+
await using assembly = await assemblyFromSource(ioHelper, cx);
2929
const stackCollection = await assembly.selectStacksV2(ALL_STACKS);
3030
return stackCollection.stackArtifacts.map(stack => stack.environment);
3131
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type * as cxapi from '@aws-cdk/cx-api';
2+
import { BorrowedAssembly } from './private/borrowed-assembly';
3+
import type { ICloudAssemblySource, IReadableCloudAssembly } from './types';
4+
5+
/**
6+
* A CloudAssemblySource that is caching its result once produced.
7+
*
8+
* Most Toolkit interactions should use a cached source. Not caching is
9+
* relevant when the source changes frequently and it is to expensive to predict
10+
* if the source has changed.
11+
*
12+
* The `CachedCloudAssembly` is both itself a readable CloudAssembly, as well as
13+
* a Cloud Assembly Source. The lifetimes of cloud assemblies produced by this
14+
* source are coupled to the lifetime of the `CachedCloudAssembly`. In other
15+
* words: the `dispose()` functions of those cloud assemblies don't do anything;
16+
* only the `dispose()` function of the `CachedCloudAssembly` will be used.
17+
*
18+
* NOTE: if we are concerned about borrowed assemblies outliving the parent
19+
* (i.e. the parent getting disposed while someone is still working with the
20+
* borrowed copies), we could consider referencing counting here. That seems
21+
* unnecessarily complicated for now, we will just assume that everyone is
22+
* being a good citizen an borrowed copies are only used by the toolkit and
23+
* immediately disposed of.
24+
*
25+
* Because `dispose()` is a no-op on the borrowed assembly, you can omit it
26+
* without changing behavior, but that would turn into a leak if we ever introduced
27+
* reference counting. Failing to dispose the result if a `produce()` call of a
28+
* `CachedCloudAssembly` is considered a bug.
29+
*/
30+
export class CachedCloudAssembly implements ICloudAssemblySource, IReadableCloudAssembly {
31+
private asm: IReadableCloudAssembly;
32+
33+
public constructor(asm: IReadableCloudAssembly) {
34+
this.asm = asm;
35+
}
36+
37+
public get cloudAssembly(): cxapi.CloudAssembly {
38+
return this.asm.cloudAssembly;
39+
}
40+
41+
public async produce(): Promise<IReadableCloudAssembly> {
42+
return new BorrowedAssembly(this.asm.cloudAssembly);
43+
}
44+
45+
public _unlock() {
46+
return this.asm._unlock();
47+
}
48+
49+
public dispose(): Promise<void> {
50+
return this.asm.dispose();
51+
}
52+
53+
public [Symbol.asyncDispose](): Promise<void> {
54+
return this.dispose();
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { StackSelectionStrategy, StackSelector } from '../../api/shared-public';
2+
export * from './cached-source';
23
export * from './source-builder';
34
export * from './types';
45

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type * as cxapi from '@aws-cdk/cx-api';
2+
import type { IReadableCloudAssembly } from '../types';
3+
4+
/**
5+
* An implementation of `IReadableCloudAssembly` that does nothing except hold on to the CloudAssembly object
6+
*
7+
* It does not own a lock, and it does not clean the underlying directory.
8+
*/
9+
export class BorrowedAssembly implements IReadableCloudAssembly {
10+
constructor(public readonly cloudAssembly: cxapi.CloudAssembly) {
11+
}
12+
13+
public async _unlock(): Promise<void> {
14+
}
15+
16+
public async dispose(): Promise<void> {
17+
}
18+
19+
public async [Symbol.asyncDispose](): Promise<void> {
20+
}
21+
}
22+

packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/cached-source.ts

-25
This file was deleted.

packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/context-aware-source.ts

+24-8
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import type { MissingContext } from '@aws-cdk/cloud-assembly-schema';
2-
import type * as cxapi from '@aws-cdk/cx-api';
32
import type { ToolkitServices } from '../../../toolkit/private';
43
import { IO } from '../../io/private';
54
import { contextproviders } from '../../shared-private';
65
import { PROJECT_CONTEXT, type Context, type IoHelper } from '../../shared-private';
76
import { ToolkitError } from '../../shared-public';
8-
import type { ICloudAssemblySource } from '../types';
7+
import type { ICloudAssemblySource, IReadableCloudAssembly } from '../types';
98

109
export interface ContextAwareCloudAssemblyProps {
1110
/**
@@ -37,9 +36,23 @@ export interface ContextAwareCloudAssemblyProps {
3736
}
3837

3938
/**
40-
* Represent the Cloud Executable and the synthesis we can do on it
39+
* A CloudAssemblySource that wraps another CloudAssemblySource and runs a lookup loop on it
40+
*
41+
* This means that if the underlying CloudAssemblySource produces a manifest
42+
* with provider queries in it, the `ContextAwareCloudAssemblySource` will
43+
* perform the necessary context lookups and invoke the underlying
44+
* `CloudAssemblySource` again with thew missing context information.
45+
*
46+
* This is only useful if the underlying `CloudAssemblySource` can respond to
47+
* this new context information (it must be a CDK app source); if it is just a
48+
* static directory, then the contents of the assembly won't change in response
49+
* to context.
50+
*
51+
* The context is passed between `ContextAwareCloudAssemblySource` and the wrapped
52+
* cloud assembly source via a contex file on disk, so the wrapped assembly source
53+
* should re-read the context file on every invocation.
4154
*/
42-
export class ContextAwareCloudAssembly implements ICloudAssemblySource {
55+
export class ContextAwareCloudAssemblySource implements ICloudAssemblySource {
4356
private canLookup: boolean;
4457
private context: Context;
4558
private contextFile: string;
@@ -55,15 +68,16 @@ export class ContextAwareCloudAssembly implements ICloudAssemblySource {
5568
/**
5669
* Produce a Cloud Assembly, i.e. a set of stacks
5770
*/
58-
public async produce(): Promise<cxapi.CloudAssembly> {
71+
public async produce(): Promise<IReadableCloudAssembly> {
5972
// We may need to run the cloud assembly source multiple times in order to satisfy all missing context
6073
// (When the source producer runs, it will tell us about context it wants to use
6174
// but it missing. We'll then look up the context and run the executable again, and
6275
// again, until it doesn't complain anymore or we've stopped making progress).
6376
let previouslyMissingKeys: Set<string> | undefined;
6477
while (true) {
65-
const assembly = await this.source.produce();
78+
const readableAsm = await this.source.produce();
6679

80+
const assembly = readableAsm.cloudAssembly;
6781
if (assembly.manifest.missing && assembly.manifest.missing.length > 0) {
6882
const missingKeysSet = missingContextKeys(assembly.manifest.missing);
6983
const missingKeys = Array.from(missingKeysSet);
@@ -99,12 +113,14 @@ export class ContextAwareCloudAssembly implements ICloudAssemblySource {
99113
}));
100114
await this.context.save(this.contextFile);
101115

102-
// Execute again
116+
// Execute again. Unlock the assembly here so that the producer can acquire
117+
// a read lock on the directory again.
118+
await readableAsm._unlock();
103119
continue;
104120
}
105121
}
106122

107-
return assembly;
123+
return readableAsm;
108124
}
109125
}
110126
}

packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/identity-source.ts

-14
This file was deleted.

packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/index.ts

-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
export * from './context-aware-source';
2-
export * from './cached-source';
3-
export * from './identity-source';
42
export * from './stack-assembly';
53
export * from './exec';
64
export * from './prepare-source';

0 commit comments

Comments
 (0)