mirror of
https://git.mirrors.martin98.com/https://github.com/actions/toolkit
synced 2025-11-20 17:51:06 +08:00
464 lines
13 KiB
TypeScript
464 lines
13 KiB
TypeScript
import * as core from '@actions/core'
|
|
import {HttpClient, HttpClientResponse} from '@actions/http-client'
|
|
import {BlockBlobClient} from '@azure/storage-blob'
|
|
import {TransferProgressEvent} from '@azure/ms-rest-js'
|
|
import * as buffer from 'buffer'
|
|
import * as fs from 'fs'
|
|
import * as stream from 'stream'
|
|
import * as util from 'util'
|
|
|
|
import * as utils from './cacheUtils'
|
|
import {SocketTimeout} from './constants'
|
|
import {DownloadOptions} from '../options'
|
|
import {retryHttpClientResponse} from './requestUtils'
|
|
|
|
import {AbortController} from '@azure/abort-controller'
|
|
|
|
/**
|
|
* Pipes the body of a HTTP response to a stream
|
|
*
|
|
* @param response the HTTP response
|
|
* @param output the writable stream
|
|
*/
|
|
async function pipeResponseToStream(
|
|
response: HttpClientResponse,
|
|
output: NodeJS.WritableStream
|
|
): Promise<void> {
|
|
const pipeline = util.promisify(stream.pipeline)
|
|
await pipeline(response.message, output)
|
|
}
|
|
|
|
/**
|
|
* Class for tracking the download state and displaying stats.
|
|
*/
|
|
export class DownloadProgress {
|
|
contentLength: number
|
|
segmentIndex: number
|
|
segmentSize: number
|
|
segmentOffset: number
|
|
receivedBytes: number
|
|
startTime: number
|
|
displayedComplete: boolean
|
|
timeoutHandle?: ReturnType<typeof setTimeout>
|
|
|
|
constructor(contentLength: number) {
|
|
this.contentLength = contentLength
|
|
this.segmentIndex = 0
|
|
this.segmentSize = 0
|
|
this.segmentOffset = 0
|
|
this.receivedBytes = 0
|
|
this.displayedComplete = false
|
|
this.startTime = Date.now()
|
|
}
|
|
|
|
/**
|
|
* Progress to the next segment. Only call this method when the previous segment
|
|
* is complete.
|
|
*
|
|
* @param segmentSize the length of the next segment
|
|
*/
|
|
nextSegment(segmentSize: number): void {
|
|
this.segmentOffset = this.segmentOffset + this.segmentSize
|
|
this.segmentIndex = this.segmentIndex + 1
|
|
this.segmentSize = segmentSize
|
|
this.receivedBytes = 0
|
|
|
|
core.debug(
|
|
`Downloading segment at offset ${this.segmentOffset} with length ${this.segmentSize}...`
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Sets the number of bytes received for the current segment.
|
|
*
|
|
* @param receivedBytes the number of bytes received
|
|
*/
|
|
setReceivedBytes(receivedBytes: number): void {
|
|
this.receivedBytes = receivedBytes
|
|
}
|
|
|
|
/**
|
|
* Returns the total number of bytes transferred.
|
|
*/
|
|
getTransferredBytes(): number {
|
|
return this.segmentOffset + this.receivedBytes
|
|
}
|
|
|
|
/**
|
|
* Returns true if the download is complete.
|
|
*/
|
|
isDone(): boolean {
|
|
return this.getTransferredBytes() === this.contentLength
|
|
}
|
|
|
|
/**
|
|
* Prints the current download stats. Once the download completes, this will print one
|
|
* last line and then stop.
|
|
*/
|
|
display(): void {
|
|
if (this.displayedComplete) {
|
|
return
|
|
}
|
|
|
|
const transferredBytes = this.segmentOffset + this.receivedBytes
|
|
const percentage = (100 * (transferredBytes / this.contentLength)).toFixed(
|
|
1
|
|
)
|
|
const elapsedTime = Date.now() - this.startTime
|
|
const downloadSpeed = (
|
|
transferredBytes /
|
|
(1024 * 1024) /
|
|
(elapsedTime / 1000)
|
|
).toFixed(1)
|
|
|
|
core.info(
|
|
`Received ${transferredBytes} of ${this.contentLength} (${percentage}%), ${downloadSpeed} MBs/sec`
|
|
)
|
|
|
|
if (this.isDone()) {
|
|
this.displayedComplete = true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a function used to handle TransferProgressEvents.
|
|
*/
|
|
onProgress(): (progress: TransferProgressEvent) => void {
|
|
return (progress: TransferProgressEvent) => {
|
|
this.setReceivedBytes(progress.loadedBytes)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts the timer that displays the stats.
|
|
*
|
|
* @param delayInMs the delay between each write
|
|
*/
|
|
startDisplayTimer(delayInMs = 1000): void {
|
|
const displayCallback = (): void => {
|
|
this.display()
|
|
|
|
if (!this.isDone()) {
|
|
this.timeoutHandle = setTimeout(displayCallback, delayInMs)
|
|
}
|
|
}
|
|
|
|
this.timeoutHandle = setTimeout(displayCallback, delayInMs)
|
|
}
|
|
|
|
/**
|
|
* Stops the timer that displays the stats. As this typically indicates the download
|
|
* is complete, this will display one last line, unless the last line has already
|
|
* been written.
|
|
*/
|
|
stopDisplayTimer(): void {
|
|
if (this.timeoutHandle) {
|
|
clearTimeout(this.timeoutHandle)
|
|
this.timeoutHandle = undefined
|
|
}
|
|
|
|
this.display()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download the cache using the Actions toolkit http-client
|
|
*
|
|
* @param archiveLocation the URL for the cache
|
|
* @param archivePath the local path where the cache is saved
|
|
*/
|
|
export async function downloadCacheHttpClient(
|
|
archiveLocation: string,
|
|
archivePath: string
|
|
): Promise<void> {
|
|
const writeStream = fs.createWriteStream(archivePath)
|
|
const httpClient = new HttpClient('actions/cache')
|
|
const downloadResponse = await retryHttpClientResponse(
|
|
'downloadCache',
|
|
async () => httpClient.get(archiveLocation)
|
|
)
|
|
|
|
// Abort download if no traffic received over the socket.
|
|
downloadResponse.message.socket.setTimeout(SocketTimeout, () => {
|
|
downloadResponse.message.destroy()
|
|
core.debug(`Aborting download, socket timed out after ${SocketTimeout} ms`)
|
|
})
|
|
|
|
await pipeResponseToStream(downloadResponse, writeStream)
|
|
|
|
// Validate download size.
|
|
const contentLengthHeader = downloadResponse.message.headers['content-length']
|
|
|
|
if (contentLengthHeader) {
|
|
const expectedLength = parseInt(contentLengthHeader)
|
|
const actualLength = utils.getArchiveFileSizeInBytes(archivePath)
|
|
|
|
if (actualLength !== expectedLength) {
|
|
throw new Error(
|
|
`Incomplete download. Expected file size: ${expectedLength}, actual file size: ${actualLength}`
|
|
)
|
|
}
|
|
} else {
|
|
core.debug('Unable to validate download, no Content-Length header')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* URL points to an Azure Storage endpoint.
|
|
*
|
|
* @param archiveLocation the URL for the cache
|
|
* @param archivePath the local path where the cache is saved
|
|
* @param options the download options with the defaults set
|
|
*/
|
|
export async function downloadCacheStorageSDK(
|
|
archiveLocation: string,
|
|
archivePath: string,
|
|
options: DownloadOptions
|
|
): Promise<void> {
|
|
const client = new BlockBlobClient(archiveLocation, undefined, {
|
|
retryOptions: {
|
|
// Override the timeout used when downloading each 4 MB chunk
|
|
// The default is 2 min / MB, which is way too slow
|
|
tryTimeoutInMs: options.timeoutInMs
|
|
}
|
|
})
|
|
|
|
const properties = await client.getProperties()
|
|
const contentLength = properties.contentLength ?? -1
|
|
|
|
if (contentLength < 0) {
|
|
// We should never hit this condition, but just in case fall back to downloading the
|
|
// file as one large stream
|
|
core.debug(
|
|
'Unable to determine content length, downloading file with http-client...'
|
|
)
|
|
|
|
await downloadCacheHttpClient(archiveLocation, archivePath)
|
|
} else {
|
|
// Use downloadToBuffer for faster downloads, since internally it splits the
|
|
// file into 4 MB chunks which can then be parallelized and retried independently
|
|
//
|
|
// If the file exceeds the buffer maximum length (~1 GB on 32-bit systems and ~2 GB
|
|
// on 64-bit systems), split the download into multiple segments
|
|
// ~2 GB = 2147483647, beyond this, we start getting out of range error. So, capping it accordingly.
|
|
|
|
// Updated segment size to 128MB = 134217728 bytes, to complete a segment faster and fail fast
|
|
const maxSegmentSize = Math.min(134217728, buffer.constants.MAX_LENGTH)
|
|
const downloadProgress = new DownloadProgress(contentLength)
|
|
|
|
const fd = fs.openSync(archivePath, 'w')
|
|
|
|
try {
|
|
downloadProgress.startDisplayTimer()
|
|
const controller = new AbortController()
|
|
const abortSignal = controller.signal
|
|
while (!downloadProgress.isDone()) {
|
|
const segmentStart =
|
|
downloadProgress.segmentOffset + downloadProgress.segmentSize
|
|
|
|
const segmentSize = Math.min(
|
|
maxSegmentSize,
|
|
contentLength - segmentStart
|
|
)
|
|
|
|
downloadProgress.nextSegment(segmentSize)
|
|
const result = await promiseWithTimeout(
|
|
options.segmentTimeoutInMs || 3600000,
|
|
client.downloadToBuffer(segmentStart, segmentSize, {
|
|
abortSignal,
|
|
concurrency: options.downloadConcurrency,
|
|
onProgress: downloadProgress.onProgress()
|
|
})
|
|
)
|
|
if (result === 'timeout') {
|
|
controller.abort()
|
|
throw new Error(
|
|
'Aborting cache download as the download time exceeded the timeout.'
|
|
)
|
|
} else if (Buffer.isBuffer(result)) {
|
|
fs.writeFileSync(fd, result)
|
|
}
|
|
}
|
|
} finally {
|
|
downloadProgress.stopDisplayTimer()
|
|
fs.closeSync(fd)
|
|
}
|
|
}
|
|
}
|
|
|
|
const promiseWithTimeout = async <T>(
|
|
timeoutMs: number,
|
|
promise: Promise<T>
|
|
): Promise<T | string> => {
|
|
let timeoutHandle: NodeJS.Timeout
|
|
const timeoutPromise = new Promise<string>(resolve => {
|
|
timeoutHandle = setTimeout(() => resolve('timeout'), timeoutMs)
|
|
})
|
|
|
|
return Promise.race([promise, timeoutPromise]).then(result => {
|
|
clearTimeout(timeoutHandle)
|
|
return result
|
|
})
|
|
}
|