1
0
Fork 0

Add option for concurrent cache downloads with timeout (#1484)

* Add option for concurrent cache downloads with timeout

* Add release notes

* Fix lint
pull/1488/head @actions/cache@3.2.2
Chad Kimes 2023-08-07 13:25:56 -04:00 committed by GitHub
parent 19e0016878
commit f74ff155bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 242 additions and 32 deletions

View File

@ -22,6 +22,7 @@
} }
], ],
"eslint-comments/no-use": "off", "eslint-comments/no-use": "off",
"no-constant-condition": ["error", { "checkLoops": false }],
"github/no-then": "off", "github/no-then": "off",
"import/no-namespace": "off", "import/no-namespace": "off",
"no-shadow": "off", "no-shadow": "off",

View File

@ -160,3 +160,7 @@
### 3.2.1 ### 3.2.1
- Updated @azure/storage-blob to `v12.13.0` - Updated @azure/storage-blob to `v12.13.0`
### 3.2.2
- Add new default cache download method to improve performance and reduce hangs [#1484](https://github.com/actions/toolkit/pull/1484)

View File

@ -84,18 +84,24 @@ test('downloadCache uses storage SDK for Azure storage URLs', async () => {
'downloadCacheStorageSDK' 'downloadCacheStorageSDK'
) )
const downloadCacheHttpClientConcurrentMock = jest.spyOn(
downloadUtils,
'downloadCacheHttpClientConcurrent'
)
const archiveLocation = 'http://foo.blob.core.windows.net/bar/baz' const archiveLocation = 'http://foo.blob.core.windows.net/bar/baz'
const archivePath = '/foo/bar' const archivePath = '/foo/bar'
await downloadCache(archiveLocation, archivePath) await downloadCache(archiveLocation, archivePath)
expect(downloadCacheStorageSDKMock).toHaveBeenCalledTimes(1) expect(downloadCacheHttpClientConcurrentMock).toHaveBeenCalledTimes(1)
expect(downloadCacheStorageSDKMock).toHaveBeenCalledWith( expect(downloadCacheHttpClientConcurrentMock).toHaveBeenCalledWith(
archiveLocation, archiveLocation,
archivePath, archivePath,
getDownloadOptions() getDownloadOptions()
) )
expect(downloadCacheStorageSDKMock).toHaveBeenCalledTimes(0)
expect(downloadCacheHttpClientMock).toHaveBeenCalledTimes(0) expect(downloadCacheHttpClientMock).toHaveBeenCalledTimes(0)
}) })
@ -109,20 +115,26 @@ test('downloadCache passes options to download methods', async () => {
'downloadCacheStorageSDK' 'downloadCacheStorageSDK'
) )
const downloadCacheHttpClientConcurrentMock = jest.spyOn(
downloadUtils,
'downloadCacheHttpClientConcurrent'
)
const archiveLocation = 'http://foo.blob.core.windows.net/bar/baz' const archiveLocation = 'http://foo.blob.core.windows.net/bar/baz'
const archivePath = '/foo/bar' const archivePath = '/foo/bar'
const options: DownloadOptions = {downloadConcurrency: 4} const options: DownloadOptions = {downloadConcurrency: 4}
await downloadCache(archiveLocation, archivePath, options) await downloadCache(archiveLocation, archivePath, options)
expect(downloadCacheStorageSDKMock).toHaveBeenCalledTimes(1) expect(downloadCacheHttpClientConcurrentMock).toHaveBeenCalledTimes(1)
expect(downloadCacheStorageSDKMock).toHaveBeenCalled() expect(downloadCacheHttpClientConcurrentMock).toHaveBeenCalled()
expect(downloadCacheStorageSDKMock).toHaveBeenCalledWith( expect(downloadCacheHttpClientConcurrentMock).toHaveBeenCalledWith(
archiveLocation, archiveLocation,
archivePath, archivePath,
getDownloadOptions(options) getDownloadOptions(options)
) )
expect(downloadCacheStorageSDKMock).toHaveBeenCalledTimes(0)
expect(downloadCacheHttpClientMock).toHaveBeenCalledTimes(0) expect(downloadCacheHttpClientMock).toHaveBeenCalledTimes(0)
}) })
@ -138,7 +150,10 @@ test('downloadCache uses http-client when overridden', async () => {
const archiveLocation = 'http://foo.blob.core.windows.net/bar/baz' const archiveLocation = 'http://foo.blob.core.windows.net/bar/baz'
const archivePath = '/foo/bar' const archivePath = '/foo/bar'
const options: DownloadOptions = {useAzureSdk: false} const options: DownloadOptions = {
useAzureSdk: false,
concurrentBlobDownloads: false
}
await downloadCache(archiveLocation, archivePath, options) await downloadCache(archiveLocation, archivePath, options)

View File

@ -5,7 +5,8 @@ import {
getUploadOptions getUploadOptions
} from '../src/options' } from '../src/options'
const useAzureSdk = true const useAzureSdk = false
const concurrentBlobDownloads = true
const downloadConcurrency = 8 const downloadConcurrency = 8
const timeoutInMs = 30000 const timeoutInMs = 30000
const segmentTimeoutInMs = 600000 const segmentTimeoutInMs = 600000
@ -18,6 +19,7 @@ test('getDownloadOptions sets defaults', async () => {
expect(actualOptions).toEqual({ expect(actualOptions).toEqual({
useAzureSdk, useAzureSdk,
concurrentBlobDownloads,
downloadConcurrency, downloadConcurrency,
timeoutInMs, timeoutInMs,
segmentTimeoutInMs, segmentTimeoutInMs,
@ -27,7 +29,8 @@ test('getDownloadOptions sets defaults', async () => {
test('getDownloadOptions overrides all settings', async () => { test('getDownloadOptions overrides all settings', async () => {
const expectedOptions: DownloadOptions = { const expectedOptions: DownloadOptions = {
useAzureSdk: false, useAzureSdk: true,
concurrentBlobDownloads: false,
downloadConcurrency: 14, downloadConcurrency: 14,
timeoutInMs: 20000, timeoutInMs: 20000,
segmentTimeoutInMs: 3600000, segmentTimeoutInMs: 3600000,

18
packages/cache/package-lock.json generated vendored
View File

@ -1,18 +1,18 @@
{ {
"name": "@actions/cache", "name": "@actions/cache",
"version": "3.2.1", "version": "3.2.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@actions/cache", "name": "@actions/cache",
"version": "3.2.1", "version": "3.2.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/core": "^1.10.0", "@actions/core": "^1.10.0",
"@actions/exec": "^1.0.1", "@actions/exec": "^1.0.1",
"@actions/glob": "^0.1.0", "@actions/glob": "^0.1.0",
"@actions/http-client": "^2.0.1", "@actions/http-client": "^2.1.1",
"@actions/io": "^1.0.1", "@actions/io": "^1.0.1",
"@azure/abort-controller": "^1.1.0", "@azure/abort-controller": "^1.1.0",
"@azure/ms-rest-js": "^2.6.0", "@azure/ms-rest-js": "^2.6.0",
@ -61,9 +61,9 @@
} }
}, },
"node_modules/@actions/http-client": { "node_modules/@actions/http-client": {
"version": "2.1.0", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.1.tgz",
"integrity": "sha512-BonhODnXr3amchh4qkmjPMUO8mFi/zLaaCeCAJZqch8iQqyDnVIkySjB38VHAC8IJ+bnlgfOqlhpyCUZHlQsqw==", "integrity": "sha512-qhrkRMB40bbbLo7gF+0vu+X+UawOvQQqNAA/5Unx774RS8poaOhThDOG6BGmxvAnxhQnDp2BG/ZUm65xZILTpw==",
"dependencies": { "dependencies": {
"tunnel": "^0.0.6" "tunnel": "^0.0.6"
} }
@ -565,9 +565,9 @@
} }
}, },
"@actions/http-client": { "@actions/http-client": {
"version": "2.1.0", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.1.tgz",
"integrity": "sha512-BonhODnXr3amchh4qkmjPMUO8mFi/zLaaCeCAJZqch8iQqyDnVIkySjB38VHAC8IJ+bnlgfOqlhpyCUZHlQsqw==", "integrity": "sha512-qhrkRMB40bbbLo7gF+0vu+X+UawOvQQqNAA/5Unx774RS8poaOhThDOG6BGmxvAnxhQnDp2BG/ZUm65xZILTpw==",
"requires": { "requires": {
"tunnel": "^0.0.6" "tunnel": "^0.0.6"
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@actions/cache", "name": "@actions/cache",
"version": "3.2.1", "version": "3.2.2",
"preview": true, "preview": true,
"description": "Actions cache lib", "description": "Actions cache lib",
"keywords": [ "keywords": [
@ -40,7 +40,7 @@
"@actions/core": "^1.10.0", "@actions/core": "^1.10.0",
"@actions/exec": "^1.0.1", "@actions/exec": "^1.0.1",
"@actions/glob": "^0.1.0", "@actions/glob": "^0.1.0",
"@actions/http-client": "^2.0.1", "@actions/http-client": "^2.1.1",
"@actions/io": "^1.0.1", "@actions/io": "^1.0.1",
"@azure/abort-controller": "^1.1.0", "@azure/abort-controller": "^1.1.0",
"@azure/ms-rest-js": "^2.6.0", "@azure/ms-rest-js": "^2.6.0",

View File

@ -20,7 +20,11 @@ import {
ITypedResponseWithError, ITypedResponseWithError,
ArtifactCacheList ArtifactCacheList
} from './contracts' } from './contracts'
import {downloadCacheHttpClient, downloadCacheStorageSDK} from './downloadUtils' import {
downloadCacheHttpClient,
downloadCacheHttpClientConcurrent,
downloadCacheStorageSDK
} from './downloadUtils'
import { import {
DownloadOptions, DownloadOptions,
UploadOptions, UploadOptions,
@ -171,16 +175,28 @@ export async function downloadCache(
const archiveUrl = new URL(archiveLocation) const archiveUrl = new URL(archiveLocation)
const downloadOptions = getDownloadOptions(options) const downloadOptions = getDownloadOptions(options)
if ( if (archiveUrl.hostname.endsWith('.blob.core.windows.net')) {
downloadOptions.useAzureSdk && if (downloadOptions.useAzureSdk) {
archiveUrl.hostname.endsWith('.blob.core.windows.net')
) {
// Use Azure storage SDK to download caches hosted on Azure to improve speed and reliability. // Use Azure storage SDK to download caches hosted on Azure to improve speed and reliability.
await downloadCacheStorageSDK(archiveLocation, archivePath, downloadOptions) await downloadCacheStorageSDK(
archiveLocation,
archivePath,
downloadOptions
)
} else if (downloadOptions.concurrentBlobDownloads) {
// Use concurrent implementation with HttpClient to work around blob SDK issue
await downloadCacheHttpClientConcurrent(
archiveLocation,
archivePath,
downloadOptions
)
} else { } else {
// Otherwise, download using the Actions http-client. // Otherwise, download using the Actions http-client.
await downloadCacheHttpClient(archiveLocation, archivePath) await downloadCacheHttpClient(archiveLocation, archivePath)
} }
} else {
await downloadCacheHttpClient(archiveLocation, archivePath)
}
} }
// Reserve Cache // Reserve Cache

View File

@ -203,6 +203,166 @@ export async function downloadCacheHttpClient(
} }
} }
/**
* Download the cache using the Actions toolkit http-client concurrently
*
* @param archiveLocation the URL for the cache
* @param archivePath the local path where the cache is saved
*/
export async function downloadCacheHttpClientConcurrent(
archiveLocation: string,
archivePath: fs.PathLike,
options: DownloadOptions
): Promise<void> {
const archiveDescriptor = await fs.promises.open(archivePath, 'w')
const httpClient = new HttpClient('actions/cache', undefined, {
socketTimeout: options.timeoutInMs,
keepAlive: true
})
try {
const res = await retryHttpClientResponse(
'downloadCacheMetadata',
async () => await httpClient.request('HEAD', archiveLocation, null, {})
)
const lengthHeader = res.message.headers['content-length']
if (lengthHeader === undefined || lengthHeader === null) {
throw new Error('Content-Length not found on blob response')
}
const length = parseInt(lengthHeader)
if (Number.isNaN(length)) {
throw new Error(`Could not interpret Content-Length: ${length}`)
}
const downloads: {
offset: number
promiseGetter: () => Promise<DownloadSegment>
}[] = []
const blockSize = 4 * 1024 * 1024
for (let offset = 0; offset < length; offset += blockSize) {
const count = Math.min(blockSize, length - offset)
downloads.push({
offset,
promiseGetter: async () => {
return await downloadSegmentRetry(
httpClient,
archiveLocation,
offset,
count
)
}
})
}
// reverse to use .pop instead of .shift
downloads.reverse()
let actives = 0
let bytesDownloaded = 0
const progress = new DownloadProgress(length)
progress.startDisplayTimer()
const progressFn = progress.onProgress()
const activeDownloads: {[offset: number]: Promise<DownloadSegment>} = []
let nextDownload:
| {offset: number; promiseGetter: () => Promise<DownloadSegment>}
| undefined
const waitAndWrite: () => Promise<void> = async () => {
const segment = await Promise.race(Object.values(activeDownloads))
await archiveDescriptor.write(
segment.buffer,
0,
segment.count,
segment.offset
)
actives--
delete activeDownloads[segment.offset]
bytesDownloaded += segment.count
progressFn({loadedBytes: bytesDownloaded})
}
while ((nextDownload = downloads.pop())) {
activeDownloads[nextDownload.offset] = nextDownload.promiseGetter()
actives++
if (actives >= (options.downloadConcurrency ?? 10)) {
await waitAndWrite()
}
}
while (actives > 0) {
await waitAndWrite()
}
} finally {
httpClient.dispose()
await archiveDescriptor.close()
}
}
async function downloadSegmentRetry(
httpClient: HttpClient,
archiveLocation: string,
offset: number,
count: number
): Promise<DownloadSegment> {
const retries = 5
let failures = 0
while (true) {
try {
const timeout = 30000
const result = await promiseWithTimeout(
timeout,
downloadSegment(httpClient, archiveLocation, offset, count)
)
if (typeof result === 'string') {
throw new Error('downloadSegmentRetry failed due to timeout')
}
return result
} catch (err) {
if (failures >= retries) {
throw err
}
failures++
}
}
}
async function downloadSegment(
httpClient: HttpClient,
archiveLocation: string,
offset: number,
count: number
): Promise<DownloadSegment> {
const partRes = await retryHttpClientResponse(
'downloadCachePart',
async () =>
await httpClient.get(archiveLocation, {
Range: `bytes=${offset}-${offset + count - 1}`
})
)
if (!partRes.readBodyBuffer) {
throw new Error('Expected HttpClientResponse to implement readBodyBuffer')
}
return {
offset,
count,
buffer: await partRes.readBodyBuffer()
}
}
declare class DownloadSegment {
offset: number
count: number
buffer: Buffer
}
/** /**
* Download the cache using the Azure Storage SDK. Only call this method if the * Download the cache using the Azure Storage SDK. Only call this method if the
* URL points to an Azure Storage endpoint. * URL points to an Azure Storage endpoint.
@ -287,12 +447,12 @@ export async function downloadCacheStorageSDK(
} }
} }
const promiseWithTimeout = async ( const promiseWithTimeout = async <T>(
timeoutMs: number, timeoutMs: number,
promise: Promise<Buffer> promise: Promise<T>
): Promise<unknown> => { ): Promise<T | string> => {
let timeoutHandle: NodeJS.Timeout let timeoutHandle: NodeJS.Timeout
const timeoutPromise = new Promise(resolve => { const timeoutPromise = new Promise<string>(resolve => {
timeoutHandle = setTimeout(() => resolve('timeout'), timeoutMs) timeoutHandle = setTimeout(() => resolve('timeout'), timeoutMs)
}) })

View File

@ -39,6 +39,12 @@ export interface DownloadOptions {
*/ */
downloadConcurrency?: number downloadConcurrency?: number
/**
* Indicates whether to use Actions HttpClient with concurrency
* for Azure Blob Storage
*/
concurrentBlobDownloads?: boolean
/** /**
* Maximum time for each download request, in milliseconds (this * Maximum time for each download request, in milliseconds (this
* option only applies when using the Azure SDK) * option only applies when using the Azure SDK)
@ -98,7 +104,8 @@ export function getUploadOptions(copy?: UploadOptions): UploadOptions {
*/ */
export function getDownloadOptions(copy?: DownloadOptions): DownloadOptions { export function getDownloadOptions(copy?: DownloadOptions): DownloadOptions {
const result: DownloadOptions = { const result: DownloadOptions = {
useAzureSdk: true, useAzureSdk: false,
concurrentBlobDownloads: true,
downloadConcurrency: 8, downloadConcurrency: 8,
timeoutInMs: 30000, timeoutInMs: 30000,
segmentTimeoutInMs: 600000, segmentTimeoutInMs: 600000,
@ -110,6 +117,10 @@ export function getDownloadOptions(copy?: DownloadOptions): DownloadOptions {
result.useAzureSdk = copy.useAzureSdk result.useAzureSdk = copy.useAzureSdk
} }
if (typeof copy.concurrentBlobDownloads === 'boolean') {
result.concurrentBlobDownloads = copy.concurrentBlobDownloads
}
if (typeof copy.downloadConcurrency === 'number') { if (typeof copy.downloadConcurrency === 'number') {
result.downloadConcurrency = copy.downloadConcurrency result.downloadConcurrency = copy.downloadConcurrency
} }