Skip to content

Commit 26fcf27

Browse files
larixerarcanismerceyz
committed
perf(nm): Speedup hardlinks-global link step by 1.5-1.7x (#4532)
* Speedup hardlinks-global link step by 1.5-1.7x * Adjust mtime comparison to detect immediate file writes * Adds a test to check store modification in the past * Adds changelog entry * chore: update changelog * chore: remove old changelog entry Co-authored-by: Maël Nison <[email protected]> Co-authored-by: merceyz <[email protected]>
1 parent 36e06d2 commit 26fcf27

File tree

4 files changed

+129
-44
lines changed

4 files changed

+129
-44
lines changed

.yarn/versions/3006ba7c.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
releases:
2+
"@yarnpkg/cli": patch
3+
"@yarnpkg/plugin-nm": patch
4+
5+
declined:
6+
- "@yarnpkg/plugin-compat"
7+
- "@yarnpkg/plugin-constraints"
8+
- "@yarnpkg/plugin-dlx"
9+
- "@yarnpkg/plugin-essentials"
10+
- "@yarnpkg/plugin-init"
11+
- "@yarnpkg/plugin-interactive-tools"
12+
- "@yarnpkg/plugin-npm-cli"
13+
- "@yarnpkg/plugin-pack"
14+
- "@yarnpkg/plugin-patch"
15+
- "@yarnpkg/plugin-pnp"
16+
- "@yarnpkg/plugin-pnpm"
17+
- "@yarnpkg/plugin-stage"
18+
- "@yarnpkg/plugin-typescript"
19+
- "@yarnpkg/plugin-version"
20+
- "@yarnpkg/plugin-workspace-tools"
21+
- "@yarnpkg/builder"
22+
- "@yarnpkg/core"
23+
- "@yarnpkg/doctor"

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Yarn now accepts sponsorships! Please give a look at our [OpenCollective](https:
1414

1515
- The node-modules linker avoids creation of circular symlinks
1616
- The node-modules linker no longer creates duplicate copies inside of aliased packages
17+
- Improved performance for `hardlinks-global` `node-modules` linker mode by 1.5x
1718

1819
## 3.2.4
1920

packages/acceptance-tests/pkg-tests-specs/sources/node-modules.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1358,7 +1358,7 @@ describe(`Node_Modules`, () => {
13581358
),
13591359
);
13601360

1361-
test(`should recover from changes to the store on next install in nmMode: cas`,
1361+
test(`should recover from changes to the store on next install in nmMode: hardlinks-global`,
13621362
makeTemporaryEnv(
13631363
{
13641364
dependencies: {
@@ -1394,6 +1394,44 @@ describe(`Node_Modules`, () => {
13941394
),
13951395
);
13961396

1397+
test(`should recover from changes to the store on next install in nmMode: hardlinks-global, when system clock is changed by the user`,
1398+
makeTemporaryEnv(
1399+
{
1400+
dependencies: {
1401+
dep: `file:./dep`,
1402+
},
1403+
},
1404+
{
1405+
nodeLinker: `node-modules`,
1406+
nmMode: `hardlinks-global`,
1407+
},
1408+
async ({path, run}) => {
1409+
await writeJson(ppath.resolve(path, `dep/package.json` as Filename), {
1410+
name: `dep`,
1411+
version: `1.0.0`,
1412+
});
1413+
1414+
const originalContent = `The same content`;
1415+
await xfs.writeFilePromise(ppath.resolve(path, `dep/index.js` as Filename), originalContent);
1416+
1417+
await run(`install`);
1418+
1419+
const modifiedContent = `The modified content`;
1420+
const depNmPath = ppath.resolve(path, `node_modules/dep/index.js` as Filename);
1421+
await xfs.writeFilePromise(depNmPath, modifiedContent);
1422+
const timeInThePast = new Date(new Date().getTime() - 10000);
1423+
await xfs.utimesPromise(depNmPath, timeInThePast, timeInThePast);
1424+
1425+
await xfs.removePromise(ppath.resolve(path, `node_modules` as Filename));
1426+
1427+
await run(`install`);
1428+
1429+
const depContent = await xfs.readFilePromise(depNmPath, `utf8`);
1430+
expect(depContent).toEqual(originalContent);
1431+
},
1432+
),
1433+
);
1434+
13971435
test(`should give priority to direct workspace dependencies over indirect regular dependencies`,
13981436
// Despite 'one-fixed-dep' and 'has-bin-entries' depend on 'no-deps:1.0.0',
13991437
// the 'no-deps:2.0.0' should be hoisted to the top-level

packages/plugin-nm/sources/NodeModulesLinker.ts

Lines changed: 66 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const STATE_FILE_VERSION = 1;
2020
const NODE_MODULES = `node_modules` as Filename;
2121
const DOT_BIN = `.bin` as Filename;
2222
const INSTALL_STATE_FILE = `.yarn-state.yml` as Filename;
23+
const MTIME_ACCURANCY = 1000;
2324

2425
type InstallState = {locatorMap: NodeModulesLocatorMap, locationTree: LocationTree, binSymlinks: BinSymlinkMap, nmMode: NodeModulesMode, mtimeMs: number};
2526
type BinSymlinkMap = Map<PortablePath, Map<Filename, PortablePath>>;
@@ -695,56 +696,67 @@ async function atomicFileWrite(tmpDir: PortablePath, dstPath: PortablePath, cont
695696
}
696697
}
697698

698-
async function copyFilePromise({srcPath, dstPath, srcMode, globalHardlinksStore, baseFs, nmMode, digest}: {srcPath: PortablePath, dstPath: PortablePath, srcMode: number, globalHardlinksStore: PortablePath | null, baseFs: FakeFS<PortablePath>, nmMode: {value: NodeModulesMode}, digest?: string}) {
699-
if (nmMode.value === NodeModulesMode.HARDLINKS_GLOBAL && globalHardlinksStore && digest) {
700-
const contentFilePath = ppath.join(globalHardlinksStore, digest.substring(0, 2) as Filename, `${digest.substring(2)}.dat` as Filename);
699+
async function copyFilePromise({srcPath, dstPath, entry, globalHardlinksStore, baseFs, nmMode}: {srcPath: PortablePath, dstPath: PortablePath, entry: DirEntry, globalHardlinksStore: PortablePath | null, baseFs: FakeFS<PortablePath>, nmMode: {value: NodeModulesMode}}) {
700+
if (entry.kind === DirEntryKind.FILE) {
701+
if (nmMode.value === NodeModulesMode.HARDLINKS_GLOBAL && globalHardlinksStore && entry.digest) {
702+
const contentFilePath = ppath.join(globalHardlinksStore, entry.digest.substring(0, 2) as Filename, `${entry.digest.substring(2)}.dat` as Filename);
701703

702-
let doesContentFileExist;
703-
try {
704-
const contentDigest = await hashUtils.checksumFile(contentFilePath, {baseFs: xfs, algorithm: `sha1`});
705-
if (contentDigest !== digest) {
706-
// If file content was modified by the user, or corrupted, we first move it out of the way
707-
const tmpPath = ppath.join(globalHardlinksStore, toFilename(`${crypto.randomBytes(16).toString(`hex`)}.tmp`));
708-
await xfs.renamePromise(contentFilePath, tmpPath);
704+
let doesContentFileExist;
705+
try {
706+
const stats = await xfs.statPromise(contentFilePath);
707+
708+
if (stats && (!entry.mtimeMs || stats.mtimeMs > entry.mtimeMs || stats.mtimeMs < entry.mtimeMs - MTIME_ACCURANCY)) {
709+
const contentDigest = await hashUtils.checksumFile(contentFilePath, {baseFs: xfs, algorithm: `sha1`});
710+
if (contentDigest !== entry.digest) {
711+
// If file content was modified by the user, or corrupted, we first move it out of the way
712+
const tmpPath = ppath.join(globalHardlinksStore, toFilename(`${crypto.randomBytes(16).toString(`hex`)}.tmp`));
713+
await xfs.renamePromise(contentFilePath, tmpPath);
714+
715+
// Then we overwrite the temporary file, thus restorting content of original file in all the linked projects
716+
const content = await baseFs.readFilePromise(srcPath);
717+
await xfs.writeFilePromise(tmpPath, content);
718+
719+
try {
720+
// Then we try to move content file back on its place, if its still free
721+
// If we fail here, it means that some other process or thread has created content file
722+
// And this is okay, we will end up with two content files, but both with original content, unlucky files will have `.tmp` extension
723+
await xfs.linkPromise(tmpPath, contentFilePath);
724+
entry.mtimeMs = new Date().getTime();
725+
await xfs.unlinkPromise(tmpPath);
726+
} catch (e) {
727+
}
728+
} else if (!entry.mtimeMs) {
729+
entry.mtimeMs = Math.ceil(stats.mtimeMs);
730+
}
731+
}
709732

710-
// Then we overwrite the temporary file, thus restorting content of original file in all the linked projects
711-
const content = await baseFs.readFilePromise(srcPath);
712-
await xfs.writeFilePromise(tmpPath, content);
733+
await xfs.linkPromise(contentFilePath, dstPath);
734+
doesContentFileExist = true;
735+
} catch (e) {
736+
doesContentFileExist = false;
737+
}
713738

739+
if (!doesContentFileExist) {
740+
const content = await baseFs.readFilePromise(srcPath);
741+
await atomicFileWrite(globalHardlinksStore, contentFilePath, content);
742+
entry.mtimeMs = new Date().getTime();
714743
try {
715-
// Then we try to move content file back on its place, if its still free
716-
// If we fail here, it means that some other process or thread has created content file
717-
// And this is okay, we will end up with two content files, but both with original content, unlucky files will have `.tmp` extension
718-
await xfs.linkPromise(tmpPath, contentFilePath);
719-
await xfs.unlinkPromise(tmpPath);
744+
await xfs.linkPromise(contentFilePath, dstPath);
720745
} catch (e) {
746+
if (e && e.code && e.code == `EXDEV`) {
747+
nmMode.value = NodeModulesMode.HARDLINKS_LOCAL;
748+
await baseFs.copyFilePromise(srcPath, dstPath);
749+
}
721750
}
722751
}
723-
await xfs.linkPromise(contentFilePath, dstPath);
724-
doesContentFileExist = true;
725-
} catch (e) {
726-
doesContentFileExist = false;
752+
} else {
753+
await baseFs.copyFilePromise(srcPath, dstPath);
727754
}
728-
729-
if (!doesContentFileExist) {
730-
const content = await baseFs.readFilePromise(srcPath);
731-
await atomicFileWrite(globalHardlinksStore, contentFilePath, content);
732-
try {
733-
await xfs.linkPromise(contentFilePath, dstPath);
734-
} catch (e) {
735-
if (e && e.code && e.code == `EXDEV`) {
736-
nmMode.value = NodeModulesMode.HARDLINKS_LOCAL;
737-
await baseFs.copyFilePromise(srcPath, dstPath);
738-
}
739-
}
755+
const mode = entry.mode & 0o777;
756+
// An optimization - files will have rw-r-r permissions (0o644) by default, we can skip chmod for them
757+
if (mode !== 0o644) {
758+
await xfs.chmodPromise(dstPath, mode);
740759
}
741-
} else {
742-
await baseFs.copyFilePromise(srcPath, dstPath);
743-
}
744-
const mode = srcMode & 0o777;
745-
// An optimization - files will have rw-r-r permissions (0o644) by default, we can skip chmod for them
746-
if (mode !== 0o644) {
747-
await xfs.chmodPromise(dstPath, mode);
748760
}
749761
}
750762

@@ -756,6 +768,7 @@ type DirEntry = {
756768
kind: DirEntryKind.FILE;
757769
mode: number;
758770
digest?: string;
771+
mtimeMs?: number;
759772
} | {
760773
kind: DirEntryKind. DIRECTORY;
761774
} | {
@@ -808,23 +821,33 @@ const copyPromise = async (dstDir: PortablePath, srcDir: PortablePath, {baseFs,
808821
allEntries = new Map(Object.entries(JSON.parse(await xfs.readFilePromise(entriesJsonPath, `utf8`)))) as Map<PortablePath, DirEntry>;
809822
} catch (e) {
810823
allEntries = await getEntriesRecursive();
811-
await atomicFileWrite(globalHardlinksStore, entriesJsonPath, Buffer.from(JSON.stringify(Object.fromEntries(allEntries))));
812824
}
813825
} else {
814826
allEntries = await getEntriesRecursive();
815827
}
816828

829+
let mtimesChanged = false;
817830
for (const [relativePath, entry] of allEntries) {
818831
const srcPath = ppath.join(srcDir, relativePath);
819832
const dstPath = ppath.join(dstDir, relativePath);
820833
if (entry.kind === DirEntryKind.DIRECTORY) {
821834
await xfs.mkdirPromise(dstPath, {recursive: true});
822835
} else if (entry.kind === DirEntryKind.FILE) {
823-
await copyFilePromise({srcPath, dstPath, srcMode: entry.mode, digest: entry.digest, nmMode, baseFs, globalHardlinksStore});
836+
const originalMtime = entry.mtimeMs;
837+
await copyFilePromise({srcPath, dstPath, entry, nmMode, baseFs, globalHardlinksStore});
838+
if (entry.mtimeMs !== originalMtime) {
839+
mtimesChanged = true;
840+
}
824841
} else if (entry.kind === DirEntryKind.SYMLINK) {
825842
await symlinkPromise(ppath.resolve(ppath.dirname(dstPath), entry.symlinkTo), dstPath);
826843
}
827844
}
845+
846+
if (nmMode.value === NodeModulesMode.HARDLINKS_GLOBAL && globalHardlinksStore && mtimesChanged && packageChecksum) {
847+
const entriesJsonPath = ppath.join(globalHardlinksStore, packageChecksum.substring(0, 2) as Filename, `${packageChecksum.substring(2)}.json` as Filename);
848+
await xfs.removePromise(entriesJsonPath);
849+
await atomicFileWrite(globalHardlinksStore, entriesJsonPath, Buffer.from(JSON.stringify(Object.fromEntries(allEntries))));
850+
}
828851
};
829852

830853
/**

0 commit comments

Comments
 (0)