Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
66b650d
feat: add streaming support to build cache provider interface and HTT…
Copilot Apr 5, 2026
64d05f1
fix lint warnings, add change files and API report update
Copilot Apr 5, 2026
d9972f5
address code review feedback: improve type annotations and JSDoc
Copilot Apr 5, 2026
79b9197
Replace manual change files with rush change --bulk generated change …
Copilot Apr 5, 2026
bc7fa96
Use createReadStream/createWriteStream from FileSystem instead of fs …
Copilot Apr 5, 2026
3dc688f
Use ensureFolderExists option in FileSystem.createWriteStream
Copilot Apr 5, 2026
9a408ab
Add streaming support to Amazon S3 and Azure Storage build cache plugins
Copilot Apr 5, 2026
2ad8219
Address code review: add documentation for type assertion and no-retr…
Copilot Apr 5, 2026
80affd8
Gate streaming build cache behind useStreamingBuildCache experiment flag
Copilot Apr 5, 2026
047586d
Update changelogs.
iclanton Apr 5, 2026
689248c
Expand FileSystem.createWriteStream.
iclanton Apr 5, 2026
f685cc9
Use destructuring on rushConfiguration in PhasedScriptAction.
iclanton Apr 5, 2026
97307f3
Clean up some duplicated types.
iclanton Apr 5, 2026
5eb0cc3
Fix CI: add missing useStreamingBuildCache to bridge plugin, fix WebC…
Copilot Apr 5, 2026
d4c86b7
DRY up duplicated code across WebClient, AmazonS3Client, and HttpBuil…
Copilot Apr 5, 2026
1254084
Address code review: use Buffer.isBuffer for clearer body length check
Copilot Apr 5, 2026
e001855
Change streaming cache APIs from Readable to NodeJS.ReadableStream
Copilot Apr 6, 2026
81af1e1
DRY up Azure/HTTP cache providers, fix bridge plugin experiment config
Copilot Apr 6, 2026
6654e21
Clean up FileSystemBuildCacheProvider.
iclanton Apr 5, 2026
94d266d
fixup! Fix CI: add missing useStreamingBuildCache to bridge plugin, f…
iclanton Apr 5, 2026
3ef0f43
Clean up WebClient.
iclanton Apr 5, 2026
1e7942a
Handle Content-Encoding decompression in streaming WebClient path
Copilot Apr 6, 2026
13562d9
Fix error message to show specific unsupported encoding value
Copilot Apr 6, 2026
ab6f07e
Fix CI: Move WebClient private members to module-level for rush-sdk t…
Copilot Apr 6, 2026
ba40685
Address review comments: fix response.resume race, stream retry bug, …
Copilot Apr 6, 2026
fd1cb75
Add unit tests for streaming cache APIs and fill buffer-based test gaps
Copilot Apr 6, 2026
bddd4ec
Fix buffer path error message to show specific unsupported encoding v…
iclanton Apr 6, 2026
84304f8
Extract _getObjectName and _validateWriteAllowed helpers in S3 provider
iclanton Apr 6, 2026
8aedb56
Extract _getContentEncodings helper to deduplicate encoding parsing
iclanton Apr 6, 2026
15c0e11
Scope cacheEntryBuffer to its branch, use cloudCacheHit flag
iclanton Apr 6, 2026
91c0e4d
Reuse a single WebClient instance in HttpBuildCacheProvider
iclanton Apr 6, 2026
48aecb2
Use property shorthand in HttpBuildCacheProvider
iclanton Apr 6, 2026
d22938a
Fix unit test issues in streaming cache tests
iclanton Apr 6, 2026
b44bc44
Rework streaming cache APIs to file-based APIs with S3 payload signing
iclanton Apr 7, 2026
b474fda
Rename file-based cache APIs to tryDownload/tryUpload for clarity
iclanton Apr 7, 2026
e800785
Replace jest.mock('node:fs') with FileSystem spies in cache tests
iclanton Apr 7, 2026
caf3fec
Clean up partial file on failed cache download
iclanton Apr 7, 2026
e894980
Ensure parent directory exists before Azure downloadToFile
iclanton Apr 7, 2026
cebdcee
fixup! Ensure parent directory exists before Azure downloadToFile
iclanton Apr 7, 2026
53f378e
Update change file descriptions, Azure JSDoc, and test names
iclanton Apr 7, 2026
d81d856
Fix S3 retry delay log unit from seconds to milliseconds
iclanton Apr 7, 2026
3a322ed
fixup! Rework streaming cache APIs to file-based APIs with S3 payload…
iclanton Apr 7, 2026
7c8e7c3
Add missing test coverage for file-based cache APIs
iclanton Apr 7, 2026
5abf492
Fix "unknown bytes" debug log wording in HttpBuildCacheProvider
iclanton Apr 7, 2026
f8fecd4
Address dmichon-msft review comments
iclanton Apr 8, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"comment": "Add optional file-based transfer APIs (`tryDownloadCacheEntryToFileAsync`, `tryUploadCacheEntryFromFileAsync`) to `ICloudBuildCacheProvider`, allowing cache plugins to transfer cache entries directly to and from files on disk without buffering entire contents in memory. Implement in `@rushstack/rush-http-build-cache-plugin`, `@rushstack/rush-amazon-s3-build-cache-plugin`, and `@rushstack/rush-azure-storage-build-cache-plugin`. Gated behind the `useDirectFileTransfersForBuildCache` experiment.",
"type": "none",
"packageName": "@microsoft/rush"
}
],
"packageName": "@microsoft/rush",
"email": "198982749+Copilot@users.noreply.github.com"
}
2 changes: 2 additions & 0 deletions common/reviews/api/rush-amazon-s3-build-cache-plugin.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { WebClient } from '@rushstack/rush-sdk/lib/utilities/WebClient';
// @public
export class AmazonS3Client {
constructor(credentials: IAmazonS3Credentials | undefined, options: IAmazonS3BuildCacheProviderOptionsAdvanced, webClient: WebClient, terminal: ITerminal);
downloadObjectToFileAsync(objectName: string, localFilePath: string): Promise<boolean>;
// (undocumented)
getObjectAsync(objectName: string): Promise<Buffer | undefined>;
// (undocumented)
Expand All @@ -25,6 +26,7 @@ export class AmazonS3Client {
static tryDeserializeCredentials(credentialString: string | undefined): IAmazonS3Credentials | undefined;
// (undocumented)
uploadObjectAsync(objectName: string, objectBuffer: Buffer): Promise<void>;
uploadObjectFromFileAsync(objectName: string, localFilePath: string): Promise<void>;
// (undocumented)
static UriEncode(input: string): string;
}
Expand Down
6 changes: 5 additions & 1 deletion common/reviews/api/rush-lib.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ export class ExperimentsConfiguration {
// @beta
export class FileSystemBuildCacheProvider {
constructor(options: IFileSystemBuildCacheProviderOptions);
getCacheEntryPath(cacheId: string): string;
readonly getCacheEntryPath: (cacheId: string) => string;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should this be mentioned in the changelog?

tryGetCacheEntryPathByIdAsync(terminal: ITerminal, cacheId: string): Promise<string | undefined>;
trySetCacheEntryBufferAsync(terminal: ITerminal, cacheId: string, entryBuffer: Buffer): Promise<string>;
}
Expand Down Expand Up @@ -345,10 +345,12 @@ export interface ICloudBuildCacheProvider {
deleteCachedCredentialsAsync(terminal: ITerminal): Promise<void>;
// (undocumented)
readonly isCacheWriteAllowed: boolean;
tryDownloadCacheEntryToFileAsync?(terminal: ITerminal, cacheId: string, localFilePath: string): Promise<boolean>;
// (undocumented)
tryGetCacheEntryBufferByIdAsync(terminal: ITerminal, cacheId: string): Promise<Buffer | undefined>;
// (undocumented)
trySetCacheEntryBufferAsync(terminal: ITerminal, cacheId: string, entryBuffer: Buffer): Promise<boolean>;
tryUploadCacheEntryFromFileAsync?(terminal: ITerminal, cacheId: string, localFilePath: string): Promise<boolean>;
// (undocumented)
updateCachedCredentialAsync(terminal: ITerminal, credential: string): Promise<void>;
// (undocumented)
Expand Down Expand Up @@ -481,6 +483,7 @@ export interface IExperimentsJson {
omitImportersFromPreventManualShrinkwrapChanges?: boolean;
printEventHooksOutputToConsole?: boolean;
rushAlerts?: boolean;
useDirectFileTransfersForBuildCache?: boolean;
useIPCScriptsInWatchMode?: boolean;
usePnpmFrozenLockfileForRushInstall?: boolean;
usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate?: boolean;
Expand Down Expand Up @@ -594,6 +597,7 @@ export interface _IOperationBuildCacheOptions {
buildCacheConfiguration: BuildCacheConfiguration;
excludeAppleDoubleFiles: boolean;
terminal: ITerminal;
useDirectFileTransfersForBuildCache: boolean;
}

// @alpha
Expand Down
8 changes: 8 additions & 0 deletions libraries/rush-lib/src/api/ExperimentsConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ export interface IExperimentsJson {
* be included in the shared build cache.
*/
omitAppleDoubleFilesFromBuildCache?: boolean;

/**
* If true, the build cache will use file-based APIs to transfer cache entries to and from cloud
* storage. This avoids loading the entire cache entry into memory, which can prevent out-of-memory
* errors for large build outputs. The cloud cache provider plugin must implement the optional
* file-based methods for this to take effect; otherwise it falls back to the buffer-based approach.
*/
useDirectFileTransfersForBuildCache?: boolean;
}

const _EXPERIMENTS_JSON_SCHEMA: JsonSchema = JsonSchema.fromLoadedObject(schemaJson);
Expand Down
42 changes: 26 additions & 16 deletions libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,11 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> i
public async runAsync(): Promise<void> {
const stopwatch: Stopwatch = Stopwatch.start();

const {
defaultSubspace,
subspacesFeatureEnabled,
pnpmOptions: { useWorkspaces }
} = this.rushConfiguration;
if (this._alwaysInstall || this._installParameter?.value) {
await measureAsyncFn(`${PERF_PREFIX}:install`, async () => {
const { doBasicInstallAsync } = await import(
Expand All @@ -373,7 +378,7 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> i
afterInstallAsync: (subspace: Subspace) =>
this.rushSession.hooks.afterInstall.promise(this, subspace, variant),
// Eventually we may want to allow a subspace to be selected here
subspace: this.rushConfiguration.defaultSubspace
subspace: defaultSubspace
});
});
}
Expand All @@ -382,14 +387,12 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> i
await measureAsyncFn(`${PERF_PREFIX}:checkInstallFlag`, async () => {
// TODO: Replace with last-install.flag when "rush link" and "rush unlink" are removed
const lastLinkFlag: FlagFile = new FlagFile(
this.rushConfiguration.defaultSubspace.getSubspaceTempFolderPath(),
defaultSubspace.getSubspaceTempFolderPath(),
RushConstants.lastLinkFlagFilename,
{}
);
// Only check for a valid link flag when subspaces is not enabled
if (!(await lastLinkFlag.isValidAsync()) && !this.rushConfiguration.subspacesFeatureEnabled) {
const useWorkspaces: boolean =
this.rushConfiguration.pnpmOptions && this.rushConfiguration.pnpmOptions.useWorkspaces;
if (!(await lastLinkFlag.isValidAsync()) && !subspacesFeatureEnabled) {
if (useWorkspaces) {
throw new Error('Link flag invalid.\nDid you run "rush install" or "rush update"?');
} else {
Expand Down Expand Up @@ -513,18 +516,27 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> i
).IPCOperationRunnerPlugin().apply(this.hooks);
}

const {
experimentsConfiguration: {
configuration: {
buildCacheWithAllowWarningsInSuccessfulBuild = false,
buildSkipWithAllowWarningsInSuccessfulBuild,
omitAppleDoubleFilesFromBuildCache: excludeAppleDoubleFiles = false,
useDirectFileTransfersForBuildCache = false,
usePnpmSyncForInjectedDependencies
}
},
isPnpm
} = this.rushConfiguration;
if (buildCacheConfiguration?.buildCacheEnabled) {
terminal.writeVerboseLine(`Incremental strategy: cache restoration`);
new CacheableOperationPlugin({
allowWarningsInSuccessfulBuild:
!!this.rushConfiguration.experimentsConfiguration.configuration
.buildCacheWithAllowWarningsInSuccessfulBuild,
allowWarningsInSuccessfulBuild: buildCacheWithAllowWarningsInSuccessfulBuild,
buildCacheConfiguration,
cobuildConfiguration,
terminal,
excludeAppleDoubleFiles:
!!this.rushConfiguration.experimentsConfiguration.configuration
.omitAppleDoubleFilesFromBuildCache
excludeAppleDoubleFiles,
useDirectFileTransfersForBuildCache
}).apply(this.hooks);

if (this._debugBuildCacheIdsParameter.value) {
Expand All @@ -534,9 +546,7 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> i
terminal.writeVerboseLine(`Incremental strategy: output preservation`);
// Explicitly disabling the build cache also disables legacy skip detection.
new LegacySkipPlugin({
allowWarningsInSuccessfulBuild:
this.rushConfiguration.experimentsConfiguration.configuration
.buildSkipWithAllowWarningsInSuccessfulBuild,
allowWarningsInSuccessfulBuild: buildSkipWithAllowWarningsInSuccessfulBuild,
terminal,
changedProjectsOnly,
isIncrementalBuildAllowed: this._isIncrementalBuildAllowed
Expand All @@ -551,12 +561,12 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> i
if (!buildCacheConfiguration?.buildCacheEnabled) {
throw new Error('You must have build cache enabled to use this option.');
}

const { BuildPlanPlugin } = await import('../../logic/operations/BuildPlanPlugin');
new BuildPlanPlugin(terminal).apply(this.hooks);
}

const { configuration: experiments } = this.rushConfiguration.experimentsConfiguration;
if (this.rushConfiguration?.isPnpm && experiments?.usePnpmSyncForInjectedDependencies) {
if (isPnpm && usePnpmSyncForInjectedDependencies) {
const { PnpmSyncCopyOperationPlugin } = await import(
'../../logic/operations/PnpmSyncCopyOperationPlugin'
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as path from 'node:path';

import { FileSystem } from '@rushstack/node-core-library';
import type { ITerminal } from '@rushstack/terminal';

Expand Down Expand Up @@ -32,19 +30,17 @@ const DEFAULT_BUILD_CACHE_FOLDER_NAME: string = 'build-cache';
* @beta
*/
export class FileSystemBuildCacheProvider {
private readonly _cacheFolderPath: string;

public constructor(options: IFileSystemBuildCacheProviderOptions) {
this._cacheFolderPath =
options.rushUserConfiguration.buildCacheFolder ||
path.join(options.rushConfiguration.commonTempFolder, DEFAULT_BUILD_CACHE_FOLDER_NAME);
}

/**
* Returns the absolute disk path for the specified cache id.
*/
public getCacheEntryPath(cacheId: string): string {
return path.join(this._cacheFolderPath, cacheId);
public readonly getCacheEntryPath: (cacheId: string) => string;

public constructor(options: IFileSystemBuildCacheProviderOptions) {
const {
rushConfiguration: { commonTempFolder },
rushUserConfiguration: { buildCacheFolder = `${commonTempFolder}/${DEFAULT_BUILD_CACHE_FOLDER_NAME}` }
} = options;
this.getCacheEntryPath = (cacheId: string) => `${buildCacheFolder}/${cacheId}`;
}

/**
Expand All @@ -55,7 +51,8 @@ export class FileSystemBuildCacheProvider {
cacheId: string
): Promise<string | undefined> {
const cacheEntryFilePath: string = this.getCacheEntryPath(cacheId);
if (await FileSystem.existsAsync(cacheEntryFilePath)) {
const cacheEntryExists: boolean = await FileSystem.existsAsync(cacheEntryFilePath);
if (cacheEntryExists) {
return cacheEntryFilePath;
} else {
return undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,35 @@ export interface ICloudBuildCacheProvider {

tryGetCacheEntryBufferByIdAsync(terminal: ITerminal, cacheId: string): Promise<Buffer | undefined>;
trySetCacheEntryBufferAsync(terminal: ITerminal, cacheId: string, entryBuffer: Buffer): Promise<boolean>;

/**
* If implemented, the build cache will prefer to use this method over
* {@link ICloudBuildCacheProvider.tryGetCacheEntryBufferByIdAsync} to avoid loading the entire
* cache entry into memory, if possible. The implementation should download the cache entry and write it
* to the specified local file path.
*
* @returns `true` if the cache entry was found and written to the file, `false` if it was
* not found. Throws on errors.
*/
tryDownloadCacheEntryToFileAsync?(
terminal: ITerminal,
cacheId: string,
localFilePath: string
): Promise<boolean>;
/**
* If implemented, the build cache will prefer to use this method over
* {@link ICloudBuildCacheProvider.trySetCacheEntryBufferAsync} to avoid loading the entire
* cache entry into memory, if possible. The implementation should read the cache entry from
* the specified local file path and upload it.
*
* @returns `true` if the cache entry was written to the cache, otherwise `false`.
*/
tryUploadCacheEntryFromFileAsync?(
terminal: ITerminal,
cacheId: string,
localFilePath: string
): Promise<boolean>;

updateCachedCredentialAsync(terminal: ITerminal, credential: string): Promise<void>;
updateCachedCredentialInteractiveAsync(terminal: ITerminal): Promise<void>;
deleteCachedCredentialsAsync(terminal: ITerminal): Promise<void>;
Expand Down
Loading
Loading