Add file-based transfer APIs to build cache provider interface and all cache plugins#5746
Add file-based transfer APIs to build cache provider interface and all cache plugins#5746Copilot wants to merge 53 commits into
Conversation
|
@copilot - add APIs to |
Added |
|
@copilot - are there async variants of those |
No, |
|
@copilot - take a look at the other |
Added |
|
@copilot - can we add stream support to the other cache plugins? |
Added streaming support to both remaining cache plugins: Amazon S3 plugin (
Azure Storage plugin (
All three cache plugins (HTTP, S3, Azure) now implement both streaming methods. All existing tests pass (45 S3, 10 Azure). |
|
@copilot - Can you put usage of this functionality behind a Rush experiment? |
Added a |
|
@copilot - this branch has conflicts. Rebase or merge main into this branch and resolve conflicts. |
…urns false Providers may catch errors internally and return false instead of throwing, leaving a partially written file on disk. Move the cleanup logic outside the catch block so it runs for any non-success case, preventing corrupted partial files from being treated as valid cache entries on subsequent builds.
…entry-for-http-plugin # Conflicts: # common/reviews/api/rush-lib.api.md # libraries/rush-lib/src/api/ExperimentsConfiguration.ts # libraries/rush-lib/src/schemas/experiments.schema.json
Merged main and resolved three conflicts ( |
| body: entryStream, | ||
| warningText: 'Could not write cache entry', | ||
| // Streaming uploads cannot be retried because the stream is consumed | ||
| maxAttempts: 1 |
There was a problem hiding this comment.
Pull request overview
This PR adds optional file-based transfer APIs to the cloud build cache provider interface and wires them through Rush’s build cache flow, enabling streaming uploads/downloads to avoid buffering large cache entries in memory (gated behind the useDirectFileTransfersForBuildCache experiment).
Changes:
- Added optional
tryDownloadCacheEntryToFileAsync/tryUploadCacheEntryFromFileAsynctoICloudBuildCacheProviderand integrated conditional usage intoOperationBuildCache. - Refactored
WebClientto support streaming responses (fetchStreamAsync) with shared request core and Content-Encoding decompression for streams. - Implemented/updated providers and tests for HTTP, Amazon S3, and Azure Blob storage to support the new file-based path.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| rush-plugins/rush-http-build-cache-plugin/src/test/HttpBuildCacheProvider.test.ts | Expands unit coverage for buffer vs file-based HTTP cache transfers and retry/credential behaviors. |
| rush-plugins/rush-http-build-cache-plugin/src/HttpBuildCacheProvider.ts | Adds file-based download/upload APIs and introduces stream-based request path via WebClient.fetchStreamAsync. |
| rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts | Plumbs the experiment flag into cache plugin options. |
| rush-plugins/rush-azure-storage-build-cache-plugin/src/AzureStorageBuildCacheProvider.ts | Adds Azure SDK-native file-based download/upload implementations and shared helpers. |
| rush-plugins/rush-amazon-s3-build-cache-plugin/src/test/AmazonS3Client.test.ts | Adds coverage for file-based GET/PUT behavior, retry expectations, and payload signing hash. |
| rush-plugins/rush-amazon-s3-build-cache-plugin/src/test/snapshots/AmazonS3Client.test.ts.snap | Snapshot update for added/modified tests. |
| rush-plugins/rush-amazon-s3-build-cache-plugin/src/AmazonS3Client.ts | Adds streaming file transfers and reinstates payload signing via SHA-256 hash for file uploads. |
| rush-plugins/rush-amazon-s3-build-cache-plugin/src/AmazonS3BuildCacheProvider.ts | Adds file-based provider APIs and centralizes object-name computation. |
| libraries/rush-lib/src/utilities/WebClient.ts | Introduces streaming response API and shared raw request core; adds stream decompression support. |
| libraries/rush-lib/src/schemas/experiments.schema.json | Adds schema entry for useDirectFileTransfersForBuildCache. |
| libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts | Threads experiment option through cache plugin initialization. |
| libraries/rush-lib/src/logic/buildCache/test/OperationBuildCache.test.ts | Updates test setup to include the new option. |
| libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts | Uses file-based download/upload when enabled and supported, with cleanup of partial downloads. |
| libraries/rush-lib/src/logic/buildCache/ICloudBuildCacheProvider.ts | Extends interface with optional file-based transfer methods. |
| libraries/rush-lib/src/logic/buildCache/FileSystemBuildCacheProvider.ts | Refactors local cache path generation and existence checks. |
| libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts | Plumbs experiments configuration into cache/skip plugin initialization. |
| libraries/rush-lib/src/api/ExperimentsConfiguration.ts | Adds typed config surface for the new experiment. |
| common/reviews/api/rush-lib.api.md | Updates API report for new interface and option surface. |
| common/reviews/api/rush-amazon-s3-build-cache-plugin.api.md | Updates API report for new S3 client methods. |
| common/changes/@microsoft/rush/copilot-stream-cache-entry-for-http-plugin_2026-04-05-03-56.json | Adds change entry describing the new APIs and gating. |
| const webFetchOptions: IGetFetchOptions | IFetchOptionsWithBody = { | ||
| verb, | ||
| headers | ||
| }; | ||
| if (verb === 'PUT' && body) { | ||
| (webFetchOptions as IFetchOptionsWithBody).body = body; | ||
| } |
| /** | ||
| * 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. | ||
| */ |
| } else if (status === 400 || status === 401 || status === 403) { | ||
| cleanup?.(); | ||
| throw new Error(`Amazon S3 responded with status code ${status} (${statusText})`); | ||
| } else { | ||
| cleanup?.(); | ||
| return { | ||
| hasNetworkError: true, | ||
| error: new Error(`Amazon S3 responded with status code ${status} (${statusText})`) | ||
| }; | ||
| } |
| // Clean up any partial file left by the failed download so it isn't | ||
| // mistaken for a valid cache entry on the next build. | ||
| try { | ||
| await FileSystem.deleteFileAsync(targetPath); |
There was a problem hiding this comment.
We should also adopt a model of download + atomic rename rather than writing directly to the file, since you can theoretically have multiple rush repos pointing at the same build cache asking for the same cache entry, and especially with modern agentic development, that's a scenario we should support.
| "type": "boolean" | ||
| }, | ||
| "useDirectFileTransfersForBuildCache": { | ||
| "description": "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.", |
There was a problem hiding this comment.
Call out that this also allows a cache entry to exceed the limit of a single Buffer
There was a problem hiding this comment.
Updated in e6d0c4f — the experiment description now also calls out that the direct-file path allows cache entries to exceed the limit of a single Buffer.
| return await new Promise<string>((resolve, reject) => { | ||
| const hash: crypto.Hash = crypto.createHash(HASH_ALGORITHM); | ||
| const stream: FileSystemReadStream = FileSystem.createReadStream(filePath); | ||
| stream.on('data', (chunk: string | Buffer) => hash.update(chunk)); | ||
| stream.on('end', () => resolve(hash.digest('hex'))); | ||
| stream.on('error', reject); | ||
| }); |
There was a problem hiding this comment.
More ergonomic to use the async iterator syntax:
| return await new Promise<string>((resolve, reject) => { | |
| const hash: crypto.Hash = crypto.createHash(HASH_ALGORITHM); | |
| const stream: FileSystemReadStream = FileSystem.createReadStream(filePath); | |
| stream.on('data', (chunk: string | Buffer) => hash.update(chunk)); | |
| stream.on('end', () => resolve(hash.digest('hex'))); | |
| stream.on('error', reject); | |
| }); | |
| const hash: crypto.Hash = crypto.createHash(HASH_ALGORITHM); | |
| const stream: FileSystemReadStream = FileSystem.createReadStream(filePath); | |
| for await (const chunk of stream) { | |
| hash.update(chunk); | |
| } | |
| return hash.digest('hex'); |
Also, do we need to clean up the stream or is it automatically taken care of on error?
There was a problem hiding this comment.
Depending on how often we perform this operation, we will likely end up wanting to move this to a worker thread that uses a single preallocated Buffer and fs.readSync alternated with hash.update until exhaustion.
There was a problem hiding this comment.
@copilot - take this suggestion and add a comment about moving this to a worker later.
There was a problem hiding this comment.
Addressed in 9f098b5. _hashFileAsync() now uses async iteration over the file stream, and I added a note that we can move the hashing work to a worker thread later if this becomes a hotspot.
| response.stream.resume(); | ||
| throw new Error(`Amazon S3 responded with status code ${response.status} (${response.statusText})`); | ||
| } | ||
| response.stream.resume(); |
There was a problem hiding this comment.
Just resume once before the if, seeing as neither branch cares about the response content.
There was a problem hiding this comment.
Addressed in 9f098b5. uploadObjectFromFileAsync() now resumes the response stream once before checking ok, since neither path needs to read the response body.
| EnvironmentConfiguration | ||
| } from '@rushstack/rush-sdk'; | ||
| import { WebClient, type IWebClientResponse } from '@rushstack/rush-sdk/lib/utilities/WebClient'; | ||
| import { |
There was a problem hiding this comment.
Should this get moved to its own package?
There was a problem hiding this comment.
We can consider doing that later.
| } | ||
|
|
||
| const bodyLength: number | 'unknown' = (body as { length: number })?.length || 'unknown'; | ||
| const bodyLengthDesc: string = Buffer.isBuffer(body) ? `${body.length} bytes` : 'unknown length'; |
There was a problem hiding this comment.
Do we need to implement the stream semantics ourselves with a file descriptor so that we can fstat and get the length to have it in the header? In general, transport integrity will be better if we have the MD5 hash and content-length.
There was a problem hiding this comment.
Updated in 9f098b5. Direct-file uploads now set Content-Length from the on-disk file size before streaming the request body. I did not add Content-MD5 here because this plugin targets generic HTTP endpoints and does not currently have a consistent integrity contract for that header.
Summary
Adds optional file-based transfer APIs to the build cache provider interface (
ICloudBuildCacheProvider) and implements them across all cache plugins (HTTP, Amazon S3, Azure Blob Storage). When enabled via theuseDirectFileTransfersForBuildCacheexperiment flag, cache entries are transferred directly between local files and cloud storage without buffering entire contents in memory, preventing out-of-memory errors for large build outputs.Details
Core changes:
ICloudBuildCacheProvidergains two optional methods:tryDownloadCacheEntryToFileAsyncandtryUploadCacheEntryFromFileAsync. Providers that don't implement them gracefully fall back to the existing buffer-based APIs.OperationBuildCacheconditionally uses the file-based path whenuseDirectFileTransfersForBuildCacheis enabled and the provider supports it. Includes cleanup of partial files on failed downloads.WebClientis refactored to extract a shared_makeRawRequestAsynccore used by both buffer and streaming request paths, with a newfetchStreamAsyncmethod and Content-Encoding decompression support for streaming responses.FileSystemin node-core-library gainscreateReadStream,createWriteStream, andcreateWriteStreamAsyncmethods (wrapped in_wrapExceptionfor consistent error handling).FileSystemBuildCacheProvideris simplified — the stream method is removed since cloud providers now handle file I/O directly.Plugin implementations:
fetchStreamAsync→pipeline()to file. Uploads viacreateReadStream→fetchStreamAsync. UsesmaxAttempts: 1for uploads (stream consumed after first attempt), with credential fallback skipped for stream bodies._hashFileAsync, then stream with the SHA-256 hash included in the AWS Signature V4 request — restoring full payload signing (no moreUNSIGNED_PAYLOAD). No retry on uploads.blobClient.downloadToFile(). Uploads viablockBlobClient.uploadFile(). Parent directory creation ensured before download.Gating:
useDirectFileTransfersForBuildCacheinexperiments.json. Defaults to off. Falls back to buffer-based APIs if the cloud provider plugin doesn't implement the file-based methods.How it was tested
HttpBuildCacheProvider(14 tests): buffer and file-based GET/SET, 404 cache miss, credential fallback skip for file uploads, write-not-allowed checks, retry behavior, pipeline assertionsAmazonS3Client(38 tests): buffer and file-based GET/SET, signed payload hash verification (not UNSIGNED-PAYLOAD), download retry on transient 5xx, no-retry on upload, credential validation, pipeline assertionsImpacted documentation
experiments.schema.jsonupdated withuseDirectFileTransfersForBuildCachedescriptioncommon/reviews/api/rush-lib.api.mdupdated with new API surfacecommon/reviews/api/node-core-library.api.mdupdated with newFileSystemstream methods