Skip to content

Commit 7254544

Browse files
authored
Fix atomic cloud cache download writes
1 parent 6867354 commit 7254544

2 files changed

Lines changed: 133 additions & 15 deletions

File tree

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

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ export class OperationBuildCache {
106106
this._useDirectFileTransfersForBuildCache = useDirectFileTransfersForBuildCache;
107107
}
108108

109+
private static _getTempLocalCacheEntryPath(finalLocalCacheEntryPath: string): string {
110+
// Derive the temp file from the destination path to ensure they are on the same volume.
111+
// In the case of a shared network drive containing the build cache, we also need to make
112+
// sure the temp path won't be shared by two parallel rush builds.
113+
const randomSuffix: string = crypto.randomBytes(8).toString('hex');
114+
return `${finalLocalCacheEntryPath}-${randomSuffix}.temp`;
115+
}
116+
109117
private static _tryGetTarUtility(terminal: ITerminal): Promise<TarExecutable | undefined> {
110118
if (!OperationBuildCache._tarUtilityPromise) {
111119
OperationBuildCache._tarUtilityPromise = TarExecutable.tryInitializeAsync(terminal);
@@ -178,15 +186,28 @@ export class OperationBuildCache {
178186
this._cloudBuildCacheProvider.tryDownloadCacheEntryToFileAsync
179187
) {
180188
// Use file-based path to avoid loading the entire cache entry into memory.
181-
// The provider downloads directly to the local cache file.
189+
// The provider downloads directly to a temp file that is atomically moved into place.
182190
const targetPath: string = this._localBuildCacheProvider.getCacheEntryPath(cacheId);
191+
const tempTargetPath: string = OperationBuildCache._getTempLocalCacheEntryPath(targetPath);
183192
try {
184-
cloudCacheHit = await this._cloudBuildCacheProvider.tryDownloadCacheEntryToFileAsync(
185-
terminal,
186-
cacheId,
187-
targetPath
188-
);
189-
if (cloudCacheHit) {
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+
});
210+
cloudCacheHit = true;
190211
localCacheEntryPath = targetPath;
191212
updateLocalCacheSuccess = true;
192213
}
@@ -200,7 +221,7 @@ export class OperationBuildCache {
200221
// mistaken for a valid cache entry on the next build. Providers may catch errors
201222
// internally and return false instead of throwing, leaving a partially written file.
202223
try {
203-
await FileSystem.deleteFileAsync(targetPath);
224+
await FileSystem.deleteFileAsync(tempTargetPath);
204225
} catch {
205226
// Ignore cleanup errors (file may not have been created)
206227
}
@@ -296,12 +317,8 @@ export class OperationBuildCache {
296317
const tarUtility: TarExecutable | undefined = await OperationBuildCache._tryGetTarUtility(terminal);
297318
if (tarUtility) {
298319
const finalLocalCacheEntryPath: string = this._localBuildCacheProvider.getCacheEntryPath(cacheId);
299-
300-
// Derive the temp file from the destination path to ensure they are on the same volume
301-
// In the case of a shared network drive containing the build cache, we also need to make
302-
// sure the the temp path won't be shared by two parallel rush builds.
303-
const randomSuffix: string = crypto.randomBytes(8).toString('hex');
304-
const tempLocalCacheEntryPath: string = `${finalLocalCacheEntryPath}-${randomSuffix}.temp`;
320+
const tempLocalCacheEntryPath: string =
321+
OperationBuildCache._getTempLocalCacheEntryPath(finalLocalCacheEntryPath);
305322

306323
const logFilePath: string = this._getTarLogFilePath(cacheId, 'tar');
307324
const tarExitCode: number = await tarUtility.tryCreateArchiveFromProjectPathsAsync({

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

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// See LICENSE in the project root for license information.
33

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

77
import type { BuildCacheConfiguration } from '../../../api/BuildCacheConfiguration';
88
import type { RushConfigurationProject } from '../../../api/RushConfigurationProject';
@@ -52,6 +52,7 @@ describe(OperationBuildCache.name, () => {
5252
packageName: 'acme-wizard',
5353
projectRelativeFolder: 'apps/acme-wizard',
5454
projectFolder: '/repo/apps/acme-wizard',
55+
projectRushTempFolder: '/repo/common/temp/project',
5556
dependencyProjects: []
5657
} as unknown as RushConfigurationProject,
5758
// Value from past tests, for consistency.
@@ -75,6 +76,106 @@ describe(OperationBuildCache.name, () => {
7576
});
7677
});
7778

79+
describe('direct file cloud cache restore', () => {
80+
afterEach(() => {
81+
Reflect.set(OperationBuildCache, '_tarUtilityPromise', undefined);
82+
jest.restoreAllMocks();
83+
});
84+
85+
function prepareDirectTransferSubject(cloudBuildCacheProvider: {
86+
tryDownloadCacheEntryToFileAsync: jest.Mock<Promise<boolean>, [ITerminal, string, string]>;
87+
}): OperationBuildCache {
88+
const terminal: Terminal = new Terminal(new StringBufferTerminalProvider());
89+
90+
return OperationBuildCache.getOperationBuildCache({
91+
buildCacheConfiguration: {
92+
buildCacheEnabled: true,
93+
getCacheEntryId: (opts: IGenerateCacheEntryIdOptions) =>
94+
`${opts.projectName}/${opts.projectStateHash}`,
95+
localCacheProvider: {
96+
getCacheEntryPath: jest.fn().mockReturnValue('/cache/acme-wizard-cache-entry'),
97+
tryGetCacheEntryPathByIdAsync: jest.fn().mockResolvedValue(undefined)
98+
},
99+
cloudCacheProvider: {
100+
isCacheWriteAllowed: false,
101+
...cloudBuildCacheProvider
102+
}
103+
} as unknown as BuildCacheConfiguration,
104+
projectOutputFolderNames: ['dist'],
105+
project: {
106+
packageName: 'acme-wizard',
107+
projectRelativeFolder: 'apps/acme-wizard',
108+
projectFolder: '/repo/apps/acme-wizard',
109+
projectRushTempFolder: '/repo/common/temp/project',
110+
dependencyProjects: []
111+
} as unknown as RushConfigurationProject,
112+
operationStateHash: '1926f30e8ed24cb47be89aea39e7efd70fcda075',
113+
terminal,
114+
phaseName: 'build',
115+
excludeAppleDoubleFiles: false,
116+
useDirectFileTransfersForBuildCache: true
117+
});
118+
}
119+
120+
it('downloads cloud cache entries to a temp file before atomically moving them into place', async () => {
121+
const tryDownloadCacheEntryToFileAsync: jest.Mock<Promise<boolean>, [ITerminal, string, string]> = jest
122+
.fn()
123+
.mockResolvedValue(true);
124+
const subject: OperationBuildCache = prepareDirectTransferSubject({
125+
tryDownloadCacheEntryToFileAsync
126+
});
127+
const terminal: Terminal = new Terminal(new StringBufferTerminalProvider());
128+
const tryUntarAsync: jest.Mock = jest.fn().mockResolvedValue(0);
129+
130+
jest.spyOn(FileSystem, 'deleteFolderAsync').mockResolvedValue();
131+
const moveAsyncSpy: jest.SpyInstance = jest.spyOn(FileSystem, 'moveAsync').mockResolvedValue();
132+
const deleteFileAsyncSpy: jest.SpyInstance = jest
133+
.spyOn(FileSystem, 'deleteFileAsync')
134+
.mockResolvedValue();
135+
Reflect.set(OperationBuildCache, '_tarUtilityPromise', Promise.resolve({ tryUntarAsync }));
136+
137+
const result: boolean = await subject.tryRestoreFromCacheAsync(terminal);
138+
139+
expect(result).toBe(true);
140+
expect(tryDownloadCacheEntryToFileAsync).toHaveBeenCalledTimes(1);
141+
const [, , tempPath]: [ITerminal, string, string] = tryDownloadCacheEntryToFileAsync.mock.calls[0];
142+
expect(tempPath).toMatch(/^\/cache\/acme-wizard-cache-entry-[0-9a-f]+\.temp$/);
143+
expect(moveAsyncSpy).toHaveBeenCalledWith({
144+
sourcePath: tempPath,
145+
destinationPath: '/cache/acme-wizard-cache-entry',
146+
overwrite: true
147+
});
148+
expect(tryUntarAsync).toHaveBeenCalledWith(
149+
expect.objectContaining({
150+
archivePath: '/cache/acme-wizard-cache-entry'
151+
})
152+
);
153+
expect(deleteFileAsyncSpy).not.toHaveBeenCalled();
154+
});
155+
156+
it('cleans up the temp file when a direct file download misses or fails', async () => {
157+
const tryDownloadCacheEntryToFileAsync: jest.Mock<Promise<boolean>, [ITerminal, string, string]> = jest
158+
.fn()
159+
.mockResolvedValue(false);
160+
const subject: OperationBuildCache = prepareDirectTransferSubject({
161+
tryDownloadCacheEntryToFileAsync
162+
});
163+
const terminal: Terminal = new Terminal(new StringBufferTerminalProvider());
164+
165+
const deleteFileAsyncSpy: jest.SpyInstance = jest
166+
.spyOn(FileSystem, 'deleteFileAsync')
167+
.mockResolvedValue();
168+
169+
const result: boolean = await subject.tryRestoreFromCacheAsync(terminal);
170+
171+
expect(result).toBe(false);
172+
expect(tryDownloadCacheEntryToFileAsync).toHaveBeenCalledTimes(1);
173+
const [, , tempPath]: [ITerminal, string, string] = tryDownloadCacheEntryToFileAsync.mock.calls[0];
174+
expect(tempPath).toMatch(/^\/cache\/acme-wizard-cache-entry-[0-9a-f]+\.temp$/);
175+
expect(deleteFileAsyncSpy).toHaveBeenCalledWith(tempPath);
176+
});
177+
});
178+
78179
describe('AppleDouble file exclusion', () => {
79180
const originalPlatform: NodeJS.Platform = process.platform;
80181

0 commit comments

Comments
 (0)