Skip to content

Commit 3225b82

Browse files
iclantonCopilot
andauthored
[rush] Avoid redundant downloads when local processes race for the same build cache entry (#5883)
* Avoid redundant downloads when local processes race for the same build cache entry When useDirectFileTransfersForBuildCache is enabled, multiple local Rush processes (e.g. parallel "rush build" invocations on the same machine or CI agent) restoring the same cache entry would each independently download it from the cloud cache provider. Use LockFile to have later processes wait for the first one to finish downloading, mirroring the pattern already used for installing the package manager (see InstallHelpers.ensureLocalPackageManagerAsync). After acquiring the lock (or failing/timing out), recheck whether the final cache entry path already exists before downloading, so a process that waited can skip the network round-trip entirely if a peer already populated it. This is strictly a best-effort optimization: if the lock cannot be acquired (for example, it's held by a process on a different machine sharing a network cache folder, where LockFile's stale-holder detection cannot see across machines) we fall back to downloading independently, exactly as before. Correctness never depends on the lock, since downloads always land in a uniquely-named temp file that is atomically renamed into place. The lock's resource name is derived by hashing the cache ID (sha1-hex), since cache IDs can contain characters (e.g. "+", "_", "/") that LockFile.getLockFilePath's resource name validation rejects, depending on the configured cacheEntryNamePattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d807794-b652-4bf8-bc4b-8ee4b2039f92 * Add change file Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d807794-b652-4bf8-bc4b-8ee4b2039f92 * fixup! Avoid redundant downloads when local processes race for the same build cache entry --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ab92302 commit 3225b82

3 files changed

Lines changed: 168 additions & 33 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"comment": "Avoid redundant downloads when multiple local Rush processes race to restore the same build cache entry (when useDirectFileTransfersForBuildCache is enabled).",
5+
"type": "none",
6+
"packageName": "@microsoft/rush"
7+
}
8+
],
9+
"packageName": "@microsoft/rush",
10+
"email": "198982749+Copilot@users.noreply.github.com"
11+
}

libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts

Lines changed: 85 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import * as path from 'node:path';
55
import * as crypto from 'node:crypto';
66

7-
import { FileSystem, type FolderItem, InternalError, Async } from '@rushstack/node-core-library';
7+
import { FileSystem, type FolderItem, InternalError, Async, LockFile } from '@rushstack/node-core-library';
88
import type { ITerminal } from '@rushstack/terminal';
99

1010
import type { RushConfigurationProject } from '../../api/RushConfigurationProject';
@@ -15,6 +15,16 @@ import { TarExecutable } from '../../utilities/TarExecutable';
1515
import { EnvironmentVariableNames } from '../../api/EnvironmentConfiguration';
1616
import type { IBaseOperationExecutionResult } from '../operations/IOperationExecutionResult';
1717

18+
/**
19+
* How long to wait to acquire the per-cache-entry download lock (see
20+
* {@link OperationBuildCache._getDirectFileTransferLockResourceName}) before giving up and
21+
* downloading independently. This is strictly a best-effort optimization to avoid redundant
22+
* downloads when multiple local Rush processes race to restore the same cache entry; it is not
23+
* required for correctness, since downloads always land in a uniquely-named temp file that is
24+
* atomically renamed into place.
25+
*/
26+
const DIRECT_FILE_TRANSFER_LOCK_MAX_WAIT_MS: number = 5 * 60 * 1000; // Five minutes
27+
1828
/**
1929
* @internal
2030
*/
@@ -66,6 +76,13 @@ interface IPathsToCache {
6676
outputFilePaths: string[];
6777
}
6878

79+
function _getDirectFileTransferLockResourceName(cacheId: string): string {
80+
// LockFile resource names must match /^[a-zA-Z0-9][a-zA-Z0-9-.]+[a-zA-Z0-9]$/, but cacheId may
81+
// contain other characters (e.g. "/") depending on the configured cacheEntryNamePattern, so hash
82+
// it into a fixed-length, lock-file-safe token.
83+
return crypto.createHash('sha1').update(cacheId).digest('hex');
84+
}
85+
6986
/**
7087
* @internal
7188
*/
@@ -188,43 +205,79 @@ export class OperationBuildCache {
188205
// Use file-based path to avoid loading the entire cache entry into memory.
189206
// The provider downloads directly to a temp file that is atomically moved into place.
190207
const targetPath: string = this._localBuildCacheProvider.getCacheEntryPath(cacheId);
191-
const tempTargetPath: string = OperationBuildCache._getTempLocalCacheEntryPath(targetPath);
208+
209+
// If multiple local Rush processes race to restore the same cache entry (e.g. parallel
210+
// "rush build" invocations on the same machine or CI agent), avoid redundant downloads by
211+
// having later processes wait for the first one to finish, mirroring the pattern used for
212+
// installing the package manager (see InstallHelpers.ensureLocalPackageManagerAsync). This
213+
// is strictly a best-effort optimization: if the lock cannot be acquired (for example,
214+
// because it is held by a process on a different machine sharing a network cache folder,
215+
// where the lock's stale-holder detection cannot see across machines) we simply fall back
216+
// to downloading independently below. Correctness never depends on the lock, because
217+
// downloads always land in a uniquely-named temp file that is atomically renamed into
218+
// place.
219+
let downloadLock: LockFile | undefined;
192220
try {
193-
const downloadedToTempFile: boolean =
194-
await this._cloudBuildCacheProvider.tryDownloadCacheEntryToFileAsync(
195-
terminal,
196-
cacheId,
197-
tempTargetPath
198-
);
199-
if (downloadedToTempFile) {
200-
await Async.runWithRetriesAsync({
201-
action: () =>
202-
FileSystem.moveAsync({
203-
sourcePath: tempTargetPath,
204-
destinationPath: targetPath,
205-
overwrite: true
206-
}),
207-
maxRetries: 2,
208-
retryDelayMs: 500
209-
});
221+
downloadLock = await LockFile.acquireAsync(
222+
path.dirname(targetPath),
223+
_getDirectFileTransferLockResourceName(cacheId),
224+
DIRECT_FILE_TRANSFER_LOCK_MAX_WAIT_MS
225+
);
226+
} catch (e) {
227+
terminal.writeVerboseLine(
228+
`Unable to acquire the local download lock for cache entry "${cacheId}"; downloading independently: ${e}`
229+
);
230+
}
231+
232+
try {
233+
// Another process may have finished populating the cache entry while we were waiting
234+
// for the lock (or the lock may not have been acquired at all).
235+
if (await FileSystem.existsAsync(targetPath)) {
210236
cloudCacheHit = true;
211237
localCacheEntryPath = targetPath;
212238
updateLocalCacheSuccess = true;
213-
}
214-
} catch (e) {
215-
terminal.writeVerboseLine(`Failed to download cache entry to local cache: ${e}`);
216-
updateLocalCacheSuccess = false;
217-
}
239+
} else {
240+
const tempTargetPath: string = OperationBuildCache._getTempLocalCacheEntryPath(targetPath);
241+
try {
242+
const downloadedToTempFile: boolean =
243+
await this._cloudBuildCacheProvider.tryDownloadCacheEntryToFileAsync(
244+
terminal,
245+
cacheId,
246+
tempTargetPath
247+
);
248+
if (downloadedToTempFile) {
249+
await Async.runWithRetriesAsync({
250+
action: () =>
251+
FileSystem.moveAsync({
252+
sourcePath: tempTargetPath,
253+
destinationPath: targetPath,
254+
overwrite: true
255+
}),
256+
maxRetries: 2,
257+
retryDelayMs: 500
258+
});
259+
cloudCacheHit = true;
260+
localCacheEntryPath = targetPath;
261+
updateLocalCacheSuccess = true;
262+
}
263+
} catch (e) {
264+
terminal.writeVerboseLine(`Failed to download cache entry to local cache: ${e}`);
265+
updateLocalCacheSuccess = false;
266+
}
218267

219-
if (!cloudCacheHit) {
220-
// Clean up any partial file left by the failed or missed download so it isn't
221-
// mistaken for a valid cache entry on the next build. Providers may catch errors
222-
// internally and return false instead of throwing, leaving a partially written file.
223-
try {
224-
await FileSystem.deleteFileAsync(tempTargetPath);
225-
} catch {
226-
// Ignore cleanup errors (file may not have been created)
268+
if (!cloudCacheHit) {
269+
// Clean up any partial file left by the failed or missed download so it isn't
270+
// mistaken for a valid cache entry on the next build. Providers may catch errors
271+
// internally and return false instead of throwing, leaving a partially written file.
272+
try {
273+
await FileSystem.deleteFileAsync(tempTargetPath);
274+
} catch {
275+
// Ignore cleanup errors (file may not have been created)
276+
}
277+
}
227278
}
279+
} finally {
280+
downloadLock?.release();
228281
}
229282
} else {
230283
const cacheEntryBuffer: Buffer | undefined =

libraries/rush-lib/src/logic/buildCache/test/OperationBuildCache.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4-
import { FileSystem, type FolderItem } from '@rushstack/node-core-library';
4+
import { FileSystem, type FolderItem, LockFile } from '@rushstack/node-core-library';
55
import { StringBufferTerminalProvider, Terminal, type ITerminal } from '@rushstack/terminal';
66

77
import type { BuildCacheConfiguration } from '../../../api/BuildCacheConfiguration';
@@ -77,6 +77,18 @@ describe(OperationBuildCache.name, () => {
7777
});
7878

7979
describe('direct file cloud cache restore', () => {
80+
let fakeLockRelease: jest.Mock;
81+
82+
beforeEach(() => {
83+
fakeLockRelease = jest.fn();
84+
// By default, simulate an uncontended lock acquisition and no pre-existing cache entry.
85+
// Individual tests may override these to exercise the lock-contention/fallback paths.
86+
jest
87+
.spyOn(LockFile, 'acquireAsync')
88+
.mockImplementation(async () => ({ release: fakeLockRelease }) as unknown as LockFile);
89+
jest.spyOn(FileSystem, 'existsAsync').mockResolvedValue(false);
90+
});
91+
8092
afterEach(() => {
8193
Reflect.set(OperationBuildCache, '_tarUtilityPromise', undefined);
8294
jest.restoreAllMocks();
@@ -151,6 +163,12 @@ describe(OperationBuildCache.name, () => {
151163
})
152164
);
153165
expect(deleteFileAsyncSpy).not.toHaveBeenCalled();
166+
expect(LockFile.acquireAsync).toHaveBeenCalledWith(
167+
'/cache',
168+
expect.stringMatching(/^[0-9a-f]{40}$/),
169+
expect.any(Number)
170+
);
171+
expect(fakeLockRelease).toHaveBeenCalledTimes(1);
154172
});
155173

156174
it('cleans up the temp file when a direct file download misses or fails', async () => {
@@ -174,6 +192,59 @@ describe(OperationBuildCache.name, () => {
174192
expect(tempPath).toMatch(/^\/cache\/acme-wizard-cache-entry-[0-9a-f]+\.temp$/);
175193
expect(deleteFileAsyncSpy).toHaveBeenCalledWith(tempPath);
176194
});
195+
196+
it('skips downloading when another process already populated the cache entry while waiting for the lock', async () => {
197+
const tryDownloadCacheEntryToFileAsync: jest.Mock<
198+
Promise<boolean>,
199+
[ITerminal, string, string]
200+
> = jest.fn();
201+
const subject: OperationBuildCache = prepareDirectTransferSubject({
202+
tryDownloadCacheEntryToFileAsync
203+
});
204+
const terminal: Terminal = new Terminal(new StringBufferTerminalProvider());
205+
const tryUntarAsync: jest.Mock = jest.fn().mockResolvedValue(0);
206+
207+
jest.spyOn(FileSystem, 'deleteFolderAsync').mockResolvedValue();
208+
// Simulate the cache entry having been fully populated (e.g. by another local process)
209+
// by the time we acquired the lock.
210+
jest.spyOn(FileSystem, 'existsAsync').mockResolvedValue(true);
211+
Reflect.set(OperationBuildCache, '_tarUtilityPromise', Promise.resolve({ tryUntarAsync }));
212+
213+
const result: boolean = await subject.tryRestoreFromCacheAsync(terminal);
214+
215+
expect(result).toBe(true);
216+
expect(tryDownloadCacheEntryToFileAsync).not.toHaveBeenCalled();
217+
expect(tryUntarAsync).toHaveBeenCalledWith(
218+
expect.objectContaining({
219+
archivePath: '/cache/acme-wizard-cache-entry'
220+
})
221+
);
222+
expect(fakeLockRelease).toHaveBeenCalledTimes(1);
223+
});
224+
225+
it('falls back to downloading independently when the download lock cannot be acquired', async () => {
226+
jest.spyOn(LockFile, 'acquireAsync').mockRejectedValue(new Error('Exceeded maximum wait time'));
227+
228+
const tryDownloadCacheEntryToFileAsync: jest.Mock<Promise<boolean>, [ITerminal, string, string]> = jest
229+
.fn()
230+
.mockResolvedValue(true);
231+
const subject: OperationBuildCache = prepareDirectTransferSubject({
232+
tryDownloadCacheEntryToFileAsync
233+
});
234+
const terminal: Terminal = new Terminal(new StringBufferTerminalProvider());
235+
const tryUntarAsync: jest.Mock = jest.fn().mockResolvedValue(0);
236+
237+
jest.spyOn(FileSystem, 'deleteFolderAsync').mockResolvedValue();
238+
jest.spyOn(FileSystem, 'moveAsync').mockResolvedValue();
239+
Reflect.set(OperationBuildCache, '_tarUtilityPromise', Promise.resolve({ tryUntarAsync }));
240+
241+
const result: boolean = await subject.tryRestoreFromCacheAsync(terminal);
242+
243+
expect(result).toBe(true);
244+
expect(tryDownloadCacheEntryToFileAsync).toHaveBeenCalledTimes(1);
245+
// No lock instance was returned, so there is nothing to release.
246+
expect(fakeLockRelease).not.toHaveBeenCalled();
247+
});
177248
});
178249

179250
describe('AppleDouble file exclusion', () => {

0 commit comments

Comments
 (0)