Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/cdktn/src/private/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,3 +443,35 @@ export function findFileAboveCwd(

return null;
}

const RENAME_ATTEMPTS = 5;
const RENAME_BACKOFF_MS = 50;
const RENAME_RETRY_CODES = ["EPERM", "EACCES", "EBUSY"];

/**
* Rename `src` to `dest`, retrying with a short backoff while Windows refuses
* because a file inside is briefly open (antivirus, search indexer).
* @param src - path to rename
* @param dest - new path
*/
export function renameSyncWithRetry(src: string, dest: string): void {
for (let attempt = 1; ; attempt++) {
try {
fs.renameSync(src, dest);
return;
} catch (err: any) {
if (
attempt >= RENAME_ATTEMPTS ||
!RENAME_RETRY_CODES.includes(err.code)
) {
throw err;
}
Atomics.wait(
new Int32Array(new SharedArrayBuffer(4)),
0,
0,
RENAME_BACKOFF_MS * 2 ** (attempt - 1),
);
}
}
}
83 changes: 72 additions & 11 deletions packages/cdktn/src/terraform-asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "./assets";
import { AssetStaging } from "./asset-staging";
import { IIgnoreStrategy } from "./ignore-strategy";
import { findFileAboveCwd } from "./private/fs";
import { findFileAboveCwd, renameSyncWithRetry } from "./private/fs";
import { ISynthesisSession } from "./synthesize";
import { addCustomSynthesis } from "./synthesize/synthesizer";
import { TerraformStack } from "./terraform-stack";
Expand Down Expand Up @@ -74,6 +74,12 @@ export interface TerraformAssetConfig {
* Extra information to fold into the hash (e.g. build instructions and
* other inputs).
*
* A bundled asset reuses the artifact an earlier synth staged for the same
* hash, so set this for build inputs the hash cannot see: files outside
* `path`, a mutable Docker tag such as `node:20`, or the options of a
* bundler without a `bundlerKey`. It is ignored when `assetHash` is set;
* change `assetHash` instead.
*
* @default - no extra hash
*/
readonly extraHash?: string;
Expand All @@ -87,6 +93,10 @@ export interface TerraformAssetConfig {
* The bundler's output shape must match `type`. See
* `AssetStagingOptions.bundler`.
*
* Under `SOURCE` hashing or a fixed `assetHash`, a synth keeps the artifact
* already staged for the same hash and `type`. See `extraHash` for inputs
* the hash cannot see; deleting the output directory also forces a rebuild.
*
* @default - the source is staged verbatim, with no build step
*/
readonly bundler?: IAssetBundler;
Expand Down Expand Up @@ -136,6 +146,8 @@ export class TerraformAsset extends Construct implements IAsset {
private readonly staging: AssetStaging;
// set when a bundler builds the artifact; names a single-file result
private readonly bundler?: IAssetBundler;
// true when the hash is known before the build, so a staged artifact can be reused
private readonly reusesStagedArtifact: boolean;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale artifact reuse — bundler swaps without bundlerKey silently serve old build output

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the trade-off jsteinich and I went through in the first review. The hash is taken at construction, and the only thing that identifies a bundler in it is its bundlerKey. Without one there's nothing stable to tell two bundlers apart: an object literal or a minified class has no reliable name. #582 asks for exactly this reuse (its repro bundler has no key), AWS CDK does the same, and IAssetBundler.bundlerKey already says an incomplete key reuses a stale artifact. LocalBundler, DockerBundler and ChainBundler all set one. Turning reuse off for keyless bundlers would bring back the rebuild-every-synth behaviour for the case in the issue, so I left the code as is. I did add the choice of bundler to the inputs the extraHash docs list, and the description calls this out as a behaviour change, with extraHash or deleting cdktf.out as the fix. Happy to revisit if you see it differently.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds good.


/**
* A Terraform Asset takes a file or directory outside of the CDK Terrain context and moves it into it.
Expand Down Expand Up @@ -177,6 +189,8 @@ export class TerraformAsset extends Construct implements IAsset {
}

this.bundler = config.bundler;
this.reusesStagedArtifact =
!!config.bundler && config.assetHashType !== AssetHashType.OUTPUT;

const outputFileName = config.bundler?.outputFileName;
if (outputFileName !== undefined) {
Expand Down Expand Up @@ -270,21 +284,68 @@ export class TerraformAsset extends Construct implements IAsset {
"..",
);

// Cleanup existing assets
const previousVersionsFolder = path.join(basePath, this.namedFolder);
if (fs.existsSync(previousVersionsFolder)) {
fs.rmSync(previousVersionsFolder, { recursive: true });
// Cleanup assets staged for previous hashes
const namedFolder = path.join(basePath, this.namedFolder);
const hashFolder = path.join(namedFolder, this.assetHash);
// Records how the artifact in the hash folder was packaged, which the
// hash does not cover: ARCHIVE and DIRECTORY share a hash.
const packagingFile = `${hashFolder}.json`;
if (fs.existsSync(namedFolder)) {
for (const entry of fs.readdirSync(namedFolder)) {
if (entry !== this.assetHash && entry !== `${this.assetHash}.json`) {
fs.rmSync(path.join(namedFolder, entry), {
recursive: true,
force: true,
});
}
}
}

// SOURCE and CUSTOM hashes are known before the build, so an artifact
// already staged for this hash and packaging is what the bundler would
// build again.
const targetPath = path.join(basePath, this.path);
const packaging = this.packaging;
const stagedPackaging = JSON.stringify({
type: AssetType[this.type],
fileName: this.fileName,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Directory asset sidecar includes fileName — causes unnecessary rebuilds on rename/move despite unchanged hash.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. For a directory the file name was just the source's basename and isn't part of path, so a renamed or moved source rebuilt for nothing. The sidecar now records fileName only for FILE and ARCHIVE. For those the name is part of the path anyway, so the ARCHIVE/FILE → DIRECTORY cases jsteinich raised still rebuild. New test: moving the source directory keeps the artifact.

});
if (
this.reusesStagedArtifact &&
fs.existsSync(targetPath) &&
fs.existsSync(packagingFile) &&
fs.readFileSync(packagingFile, "utf8") === stagedPackaging
) {
return;
}
fs.rmSync(packagingFile, { force: true });

if (packaging.producesDirectory) {
fs.mkdirSync(targetPath, { recursive: true });
} else {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
if (!this.reusesStagedArtifact) {
fs.rmSync(hashFolder, { recursive: true, force: true });
if (this.packaging.producesDirectory) {
fs.mkdirSync(targetPath, { recursive: true });
} else {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
}
this.staging.stage(targetPath);
return;
}

this.staging.stage(targetPath);
// Stage next to the hash folder and move it into place only once staging
// succeeded, so a failed or interrupted build never leaves a folder that
// a later synth would reuse.
const stagingFolder = `${hashFolder}.staging`;
fs.mkdirSync(stagingFolder, { recursive: true });
try {
this.staging.stage(
this.packaging.producesDirectory
? stagingFolder
: path.join(stagingFolder, this.fileName),
);
fs.rmSync(hashFolder, { recursive: true, force: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing retry on cleanup rmSync (also on line 347) — inconsistent with the new renameSyncWithRetry, can cause spurious Windows failures.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6b5ac3a. There's now an rmSyncWithRetry next to renameSyncWithRetry that uses Node's built-in maxRetries/retryDelay (it retries EBUSY/EPERM/ENOTEMPTY), with the same attempt count and base delay as the rename. It's used for the hash folder before the rename, for the staging-folder cleanup, and for the sweep of other hashes' folders. So a staging folder left behind by a failed cleanup is also removed with retries on the next synth.

renameSyncWithRetry(stagingFolder, hashFolder);
} finally {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This finally cleanup can mask real build errors — a locked-file cleanup failure overwrites the original error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The finally is now a catch that tries to remove the staging folder and then rethrows the original error. If that removal fails too, it's ignored: the next synth sweeps <hash>.staging anyway, since its name isn't the hash. On success the rename has already moved the folder, so there's nothing to clean up. New test: the build throws and removing the staging folder throws EBUSY; the synth reports the build error, and the next synth removes the leftover and rebuilds.

fs.rmSync(stagingFolder, { recursive: true, force: true });
}
fs.writeFileSync(packagingFile, stagedPackaging);
}
}
148 changes: 148 additions & 0 deletions packages/cdktn/test/canonical-asset-hash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
AssetHashType,
BundleResult,
IAsset,
IAssetBundler,
} from "../src";
import { CANONICAL_ASSET_HASHES } from "../src/features";
import { TerraformModuleAsset } from "../src/terraform-module-asset";
Expand Down Expand Up @@ -899,6 +900,153 @@ describe("TerraformAsset stages inside the stack's own directory (#380)", () =>
});
});

describe("TerraformAsset reuses a bundled artifact across synths (#582)", () => {
let srcDir: string;
let outdir: string;

beforeEach(() => {
srcDir = createTempDir();
fs.writeFileSync(path.join(srcDir, "a.txt"), "content");
outdir = createTempDir();
});

afterEach(() => {
fs.rmSync(srcDir, { recursive: true, force: true });
fs.rmSync(outdir, { recursive: true, force: true });
});

// Each synth gets a fresh App writing to the same outdir, as consecutive
// `cdktn synth` runs do.
const build = (type: AssetType, bundler: IAssetBundler) => {
const app = Testing.app({
outdir,
context: { [CANONICAL_ASSET_HASHES]: "true" },
});
const s = new TerraformStack(app, "s");
const asset = new TerraformAsset(s, "asset", {
path: srcDir,
type,
bundler,
});
return { app, asset, stackDir: path.join(outdir, "stacks", s.node.id) };
};

const synth = (type: AssetType, bundler: IAssetBundler) => {
const built = build(type, bundler);
built.app.synth();
return built;
};

const countingBundler = (bundlerKey?: string) => {
const bundler = {
runs: 0,
bundlerKey,
bundle: (opts: { outputDir: string }) => {
bundler.runs++;
fs.writeFileSync(path.join(opts.outputDir, "index.txt"), "built");
return BundleResult.directory(opts.outputDir);
},
};
return bundler;
};

const fileBundler: IAssetBundler = {
outputFileName: "out.txt",
bundle: (opts) => {
const file = path.join(opts.outputDir, "out.txt");
fs.writeFileSync(file, "built");
return BundleResult.file(file);
},
};

test("a second synth with the same hash does not rerun the bundler", () => {
const bundler = countingBundler();

const first = synth(AssetType.ARCHIVE, bundler);
const second = synth(AssetType.ARCHIVE, bundler);

expect(second.asset.assetHash).toBe(first.asset.assetHash);
expect(bundler.runs).toBe(1);
expect(fs.existsSync(path.join(second.stackDir, second.asset.path))).toBe(
true,
);
});

test("a changed hash reruns the bundler and removes the old artifact", () => {
const v1 = countingBundler("v1");
const v2 = countingBundler("v2");

const first = synth(AssetType.ARCHIVE, v1);
const second = synth(AssetType.ARCHIVE, v2);

expect(second.asset.assetHash).not.toBe(first.asset.assetHash);
expect(v2.runs).toBe(1);
const namedFolder = path.dirname(
path.dirname(path.join(second.stackDir, second.asset.path)),
);
expect(fs.readdirSync(namedFolder)).toEqual([
second.asset.assetHash,
`${second.asset.assetHash}.json`,
]);
});

test.each([
["ARCHIVE", AssetType.ARCHIVE, countingBundler()],
["FILE", AssetType.FILE, fileBundler],
])(
"changing the type from %s to DIRECTORY rebuilds the artifact",
(_, type, previous) => {
const first = synth(type, previous);
const bundler = countingBundler();
const second = synth(AssetType.DIRECTORY, bundler);

expect(second.asset.assetHash).toBe(first.asset.assetHash);
expect(bundler.runs).toBe(1);
expect(
fs.readdirSync(path.join(second.stackDir, second.asset.path)),
).toEqual(["index.txt"]);
},
);

test("a failed build leaves no artifact for the next synth to reuse", () => {
const failing = {
bundle: (opts: { outputDir: string }) => {
fs.writeFileSync(path.join(opts.outputDir, "partial.txt"), "partial");
throw new Error("build failed");
},
};

const failed = build(AssetType.DIRECTORY, failing);
expect(() => failed.app.synth()).toThrow(/build failed/);
expect(fs.existsSync(path.join(failed.stackDir, failed.asset.path))).toBe(
false,
);

const bundler = countingBundler();
const { asset, stackDir } = synth(AssetType.DIRECTORY, bundler);
expect(bundler.runs).toBe(1);
expect(fs.readdirSync(path.join(stackDir, asset.path))).toEqual([
"index.txt",
]);
});

test("a rename refused with EPERM is retried", () => {
const spy = jest.spyOn(fs, "renameSync").mockImplementationOnce(() => {
throw Object.assign(new Error("operation not permitted"), {
code: "EPERM",
});
});
try {
const { asset, stackDir } = synth(AssetType.DIRECTORY, countingBundler());
expect(fs.readdirSync(path.join(stackDir, asset.path))).toEqual([
"index.txt",
]);
} finally {
spy.mockRestore();
}
});
});

describe("TerraformModuleAsset with the canonicalAssetHashes flag", () => {
let rootDir: string;
let moduleA: string;
Expand Down
Loading