Skip to content

Commit 8e71446

Browse files
adrians5jclaude
andauthored
perf(build): skip cache→dist copy via content hash, drop marker file (#5422)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bd800d6 commit 8e71446

5 files changed

Lines changed: 93 additions & 86 deletions

File tree

scripts/buildPackages/src/buildPackages.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import path from "path";
1515
import { hideBin } from "yargs/helpers";
1616
import { PackageBuildError } from "./PackageBuildError";
1717
import { queueMetaWrite } from "./writeMetaQueue";
18-
import { writeDistBuildHash, isExperimentalBuildCacheEnabled } from "./distBuildHash";
1918

2019
const argv = yargs(hideBin(process.argv)).parse();
2120

@@ -90,16 +89,13 @@ export const buildPackages = async () => {
9089
try {
9190
await buildPackage(pkg, options.buildOverrides, "inherit", options.safeReplace);
9291

93-
// EXPERIMENTAL (opt-in): record the source hash (in dist as a marker
94-
// and in build meta) so a later build treats this package as a cache
95-
// hit and skips the copy. Off by default — see distBuildHash.ts.
96-
if (isExperimentalBuildCacheEnabled()) {
97-
const sourceHash = await getPackageSourceHash(pkg);
98-
writeDistBuildHash(pkg, sourceHash);
99-
const meta = getBuildMeta();
100-
meta.packages[pkg.packageJson.name] = { sourceHash };
101-
writeJsonFileSync(META_FILE_PATH, meta);
102-
}
92+
// Record the source hash in build meta so a later build treats this
93+
// package as a cache hit (the cache→dist copy is then skipped when
94+
// dist already matches — content hash).
95+
const sourceHash = await getPackageSourceHash(pkg);
96+
const meta = getBuildMeta();
97+
meta.packages[pkg.packageJson.name] = { sourceHash };
98+
writeJsonFileSync(META_FILE_PATH, meta);
10399

104100
sendNotification(`Webiny Build (${projectFolder})`, "Build completed successfully");
105101
} catch (err) {
@@ -143,12 +139,6 @@ export const buildPackages = async () => {
143139

144140
// Store package hash
145141
const sourceHash = await getPackageSourceHash(pkg);
146-
// EXPERIMENTAL (opt-in): stamp dist so a
147-
// later no-op build can skip the
148-
// cache→dist copy for this package.
149-
if (isExperimentalBuildCacheEnabled()) {
150-
writeDistBuildHash(pkg, sourceHash);
151-
}
152142
await queueMetaWrite(async () => {
153143
const currentMeta = getBuildMeta();
154144
currentMeta.packages[pkg.packageJson.name] = {

scripts/buildPackages/src/buildSinglePackage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CACHE_FOLDER_PATH } from "./constants";
55
import { fork, type StdioOptions } from "child_process";
66
import path from "path";
77
import { deserializeError } from "serialize-error";
8+
import { recordCacheHash } from "./distContentHash";
89

910
export const buildPackage = async (
1011
pkg: Package,
@@ -42,4 +43,8 @@ export const buildPackage = async (
4243
// Delete previous cache!
4344
await fs.emptyDir(cacheFolderPath);
4445
await fs.copy(buildFolder, cacheFolderPath);
46+
47+
// Record the cache's content hash so a later build can skip the cache→dist
48+
// copy by hashing dist alone.
49+
await recordCacheHash(pkg);
4550
};

scripts/buildPackages/src/distBuildHash.ts

Lines changed: 0 additions & 46 deletions
This file was deleted.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import fs from "fs-extra";
2+
import path from "path";
3+
import { hashFolderAsync } from "@webiny/stdlib/node";
4+
import { getBuildOutputFolder } from "./getBuildOutputFolder";
5+
import { CACHE_FOLDER_PATH } from "./constants";
6+
import type { Package } from "./types";
7+
8+
// Sidecar file holding the content hash of a package's cached build, stored
9+
// next to its cache folder: `.webiny/cached-packages/<name>.hash`. Written when
10+
// the cache is generated, read at runtime to decide whether dist already
11+
// matches — so only `dist` is hashed at build time, never the cache twice.
12+
function storedHashPath(pkg: Package): string {
13+
return path.join(CACHE_FOLDER_PATH, `${pkg.packageJson.name}.hash`);
14+
}
15+
16+
export async function hashBuildOutput(dir: string): Promise<string | null> {
17+
if (!fs.existsSync(dir)) {
18+
return null;
19+
}
20+
try {
21+
const { hash } = await hashFolderAsync(dir, { excludeFiles: [".webiny-build-hash"] });
22+
return hash;
23+
} catch {
24+
return null;
25+
}
26+
}
27+
28+
export function readStoredCacheHash(pkg: Package): string | null {
29+
const file = storedHashPath(pkg);
30+
try {
31+
return fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim() : null;
32+
} catch {
33+
return null;
34+
}
35+
}
36+
37+
export function writeStoredCacheHash(pkg: Package, hash: string): void {
38+
const file = storedHashPath(pkg);
39+
fs.ensureDirSync(path.dirname(file));
40+
fs.writeFileSync(file, hash);
41+
}
42+
43+
/**
44+
* Hashes a package's dist and records it as the cache's content hash. Call
45+
* right after (re)generating the cache from dist, so a later build can verify
46+
* freshness by hashing dist alone.
47+
*/
48+
export async function recordCacheHash(pkg: Package): Promise<void> {
49+
const hash = await hashBuildOutput(getBuildOutputFolder(pkg));
50+
if (hash) {
51+
writeStoredCacheHash(pkg, hash);
52+
}
53+
}
54+
55+
/**
56+
* Whether the package's current dist already matches the cached build, using
57+
* the stored cache hash (no second hashing of the cache). Returns false when
58+
* no hash was stored yet (forces a copy, which then records it).
59+
*/
60+
export async function distMatchesCache(pkg: Package): Promise<boolean> {
61+
const stored = readStoredCacheHash(pkg);
62+
if (!stored) {
63+
return false;
64+
}
65+
const distHash = await hashBuildOutput(getBuildOutputFolder(pkg));
66+
return distHash !== null && distHash === stored;
67+
}

scripts/buildPackages/src/getBatches.ts

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,7 @@ import { getBuildOutputFolder } from "./getBuildOutputFolder";
99
import { getPackageSourceHash } from "./getPackageSourceHash";
1010
import { getBuildMeta } from "./getBuildMeta";
1111
import { getPackageCacheFolderPath } from "./getPackageCacheFolderPath";
12-
import {
13-
distBuildHashMatches,
14-
writeDistBuildHash,
15-
isExperimentalBuildCacheEnabled
16-
} from "./distBuildHash";
12+
import { distMatchesCache, recordCacheHash } from "./distContentHash";
1713

1814
const { green } = chalk;
1915

@@ -28,9 +24,6 @@ export async function getBatches(options: GetBatchesOptions = {}) {
2824

2925
const packagesNoCache: Package[] = [];
3026
const packagesUseCache: Package[] = [];
31-
// Source hash of each cache-hit package, reused below to skip the cache→dist
32-
// copy when the existing dist was already built from that same hash.
33-
const cacheHitHashes = new Map<string, string>();
3427

3528
let workspacesPackages = (
3629
getPackages({
@@ -70,7 +63,6 @@ export async function getBatches(options: GetBatchesOptions = {}) {
7063

7164
if (packageMeta.sourceHash === sourceHash) {
7265
packagesUseCache.push(workspacePackage);
73-
cacheHitHashes.set(workspacePackage.name, sourceHash);
7466
} else {
7567
packagesNoCache.push(workspacePackage);
7668
}
@@ -123,31 +115,30 @@ export async function getBatches(options: GetBatchesOptions = {}) {
123115
}
124116
}
125117

126-
const experimentalCache = isExperimentalBuildCacheEnabled();
118+
// Skip the cache→dist copy for packages whose dist already matches the
119+
// cache byte-for-byte (content hash). Reads actual bytes in parallel, so
120+
// it can't go stale from out-of-band dist writes (`webiny watch`, manual
121+
// edits) — those change the hash and force a copy.
122+
const fresh = await Promise.all(packagesUseCache.map(pkg => distMatchesCache(pkg)));
127123

128-
let copied = 0;
124+
const restored: Package[] = [];
129125
for (let i = 0; i < packagesUseCache.length; i++) {
130126
const workspacePackage = packagesUseCache[i];
131-
const sourceHash = cacheHitHashes.get(workspacePackage.name)!;
132127

133-
// EXPERIMENTAL (opt-in): skip the copy when dist was already
134-
// built/restored from this exact source hash — the bytes on disk are
135-
// already identical. Off by default; the marker can go stale if
136-
// something writes dist out of band (e.g. `webiny watch`).
137-
if (experimentalCache && distBuildHashMatches(workspacePackage, sourceHash)) {
128+
if (fresh[i]) {
138129
continue;
139130
}
140131

141132
const cacheFolderPath = path.join(CACHE_FOLDER_PATH, workspacePackage.packageJson.name);
142133
fs.copySync(cacheFolderPath, getBuildOutputFolder(workspacePackage));
143-
if (experimentalCache) {
144-
writeDistBuildHash(workspacePackage, sourceHash);
145-
}
146-
copied++;
134+
restored.push(workspacePackage);
147135
}
148136

149-
if (experimentalCache && copied > 0) {
150-
console.log(`Restored ${green(copied)} package(s) from cache into dist.`);
137+
if (restored.length) {
138+
// dist now equals the cache — record the hash so the next build can
139+
// verify freshness by hashing dist alone.
140+
await Promise.all(restored.map(pkg => recordCacheHash(pkg)));
141+
console.log(`Restored ${green(restored.length)} package(s) from cache into dist.`);
151142
}
152143
} else {
153144
if (useCache) {

0 commit comments

Comments
 (0)