Summary
@actions/cache selects its download strategy from the archive URL's hostname (packages/cache/src/internal/cacheHttpClient.ts, downloadCache):
if (archiveUrl.hostname.endsWith('.blob.core.windows.net')) {
// concurrent ranged download (downloadCacheHttpClientConcurrent / storage SDK)
} else {
await downloadCacheHttpClient(archiveLocation, archivePath) // one stream, no Range, no keepAlive
}
Any cache backend that is not Azure Blob — every self-hosted implementation of the results API, and GHES deployments fronting their own storage — is pinned to one HTTP stream. downloadConcurrency / concurrentBlobDownloads are computed and discarded; restoreCacheV2 sets useAzureSdk: true but that flag is only read inside the Azure branch. There is no input or environment variable that changes this.
Measurements
Self-hosted runner (Kubernetes pod, Scaleway), one 320 MB cache object served from S3-compatible object storage via a presigned URL:
| path |
throughput |
actions/cache restore (today) |
~15 MB/s |
curl single stream, same URL |
54 MB/s |
8 parallel 40 MB Range GETs, same URL |
143 MB/s |
We since built a drop-in action that reuses @actions/cache's own twirp client, getCacheVersion and extractTar and swaps only the downloader for a parallel ranged one. Same 320 MB entry, cold runner per leg, sha256-verified: actions/cache/restore 5.6 s; ranged 3.5 s with the transfer itself at 140 MB/s over 9 requests. The object store, network and URL are fine; the client shape is the limit. Blacksmith has documented the same gate and worked around it by rewriting hostnames to Azure-shaped ones (their write-up on cache speed).
Why #1943 does not fix it
#1943 lifts useAzureSdk out of the check but keeps archiveUrl.hostname.endsWith('.blob.core.windows.net') as a condition for the concurrent HTTP path; the only new path it opens for non-Azure hosts is downloadCacheStorageSDK, which speaks the Azure Blob protocol (BlockBlobClient) and cannot be pointed at an S3 endpoint.
Suggested fix
Make the concurrent HTTP downloader (downloadCacheHttpClientConcurrent) usable regardless of hostname — for example when concurrentBlobDownloads is set, or via a DownloadOptions flag / env var — and let the server's Accept-Ranges / a Range probe decide, rather than the hostname. Two details for non-Azure backends:
- presigned S3 URLs are typically signed for
GET only, so the initial HEAD used for content-length answers 403; a Range: bytes=0-0 GET returns the total in content-range without needing HEAD;
- 4 MiB blocks are far below the per-request overhead of object stores: 8×4 MiB measured 17 MB/s against 143 MB/s for 8×40 MB on the same object. A configurable block size, or sizing by object length, matters.
Happy to open a PR along these lines if maintainers agree on the shape.
Summary
@actions/cacheselects its download strategy from the archive URL's hostname (packages/cache/src/internal/cacheHttpClient.ts,downloadCache):Any cache backend that is not Azure Blob — every self-hosted implementation of the results API, and GHES deployments fronting their own storage — is pinned to one HTTP stream.
downloadConcurrency/concurrentBlobDownloadsare computed and discarded;restoreCacheV2setsuseAzureSdk: truebut that flag is only read inside the Azure branch. There is no input or environment variable that changes this.Measurements
Self-hosted runner (Kubernetes pod, Scaleway), one 320 MB cache object served from S3-compatible object storage via a presigned URL:
actions/cacherestore (today)curlsingle stream, same URLRangeGETs, same URLWe since built a drop-in action that reuses
@actions/cache's own twirp client,getCacheVersionandextractTarand swaps only the downloader for a parallel ranged one. Same 320 MB entry, cold runner per leg, sha256-verified:actions/cache/restore5.6 s; ranged 3.5 s with the transfer itself at 140 MB/s over 9 requests. The object store, network and URL are fine; the client shape is the limit. Blacksmith has documented the same gate and worked around it by rewriting hostnames to Azure-shaped ones (their write-up on cache speed).Why #1943 does not fix it
#1943 lifts
useAzureSdkout of the check but keepsarchiveUrl.hostname.endsWith('.blob.core.windows.net')as a condition for the concurrent HTTP path; the only new path it opens for non-Azure hosts isdownloadCacheStorageSDK, which speaks the Azure Blob protocol (BlockBlobClient) and cannot be pointed at an S3 endpoint.Suggested fix
Make the concurrent HTTP downloader (
downloadCacheHttpClientConcurrent) usable regardless of hostname — for example whenconcurrentBlobDownloadsis set, or via aDownloadOptionsflag / env var — and let the server'sAccept-Ranges/ aRangeprobe decide, rather than the hostname. Two details for non-Azure backends:GETonly, so the initialHEADused forcontent-lengthanswers 403; aRange: bytes=0-0GET returns the total incontent-rangewithout needing HEAD;Happy to open a PR along these lines if maintainers agree on the shape.