UNPKG

@useblacksmith/cache

Version:
641 lines 29.2 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.downloadCacheStorageSDK = exports.downloadCacheHttpClientConcurrent = exports.downloadCacheHttpClient = exports.downloadCacheAxiosMultiPart = exports.DownloadProgress = void 0; const core = __importStar(require("@actions/core")); const http_client_1 = require("@actions/http-client"); const storage_blob_1 = require("@azure/storage-blob"); const buffer = __importStar(require("buffer")); const fs = __importStar(require("fs")); const stream = __importStar(require("stream")); const util = __importStar(require("util")); const constants_1 = require("./constants"); const requestUtils_1 = require("./requestUtils"); const abort_controller_1 = require("@azure/abort-controller"); const axios_retry_1 = __importDefault(require("axios-retry")); const axios_1 = __importDefault(require("axios")); const cacheHttpClient_1 = require("./cacheHttpClient"); /** * Pipes the body of a HTTP response to a stream * * @param response the HTTP response * @param output the writable stream */ function pipeResponseToStream(response, output, progress) { return __awaiter(this, void 0, void 0, function* () { const pipeline = util.promisify(stream.pipeline); const reportProgress = new stream.Transform({ transform(chunk, _encoding, callback) { if (progress) { progress.setReceivedBytes(progress.getTransferredBytes() + chunk.length); } this.push(chunk); callback(); } }); yield pipeline(response.message, reportProgress, output); }); } function pipeAxiosResponseToStream(response, output, progress) { return __awaiter(this, void 0, void 0, function* () { const reportProgress = new stream.Transform({ transform(chunk, _encoding, callback) { if (progress) { progress.setReceivedBytes(progress.getTransferredBytes() + chunk.length); } this.push(chunk); callback(); } }); yield response.data.pipe(reportProgress).pipe(output); }); } function reportStall() { return __awaiter(this, void 0, void 0, function* () { try { core.debug('Reporting stall to api.blacksmith.sh'); const httpClient = (0, cacheHttpClient_1.createHttpClient)(); yield promiseWithTimeout(10000, httpClient.postJson((0, cacheHttpClient_1.getCacheApiUrl)('report-stall'), {})); } catch (error) { core.warning('Failed to report failure to api.blacksmith.sh'); } }); } /** * Class for tracking the download state and displaying stats. */ class DownloadProgress { constructor(contentLength) { this.contentLength = contentLength; this.segmentIndex = 0; this.segmentSize = 0; this.segmentOffset = 0; this.receivedBytes = 0; this.displayedComplete = false; this.highWaterTime = 0; 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) { 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) { this.receivedBytes = receivedBytes; } /** * Returns the total number of bytes transferred. */ getTransferredBytes() { return this.segmentOffset + this.receivedBytes; } /** * Returns true if the download is complete. */ isDone() { return this.getTransferredBytes() === this.contentLength; } /** * Prints the current download stats. Once the download completes, this will print one * last line and then stop. */ display() { 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() { return (progress) => { this.highWaterTime = Date.now(); this.setReceivedBytes(progress.loadedBytes); }; } /** * Starts the timer that displays the stats. * * @param delayInMs the delay between each write */ startDisplayTimer(delayInMs = 1000) { const displayCallback = () => { 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() { if (this.timeoutHandle) { clearTimeout(this.timeoutHandle); this.timeoutHandle = undefined; } this.display(); } } exports.DownloadProgress = DownloadProgress; function downloadCacheAxiosMultiPart(archiveLocation, archivePath) { return __awaiter(this, void 0, void 0, function* () { const CONCURRENCY = 10; core.info(`Downloading with ${CONCURRENCY} concurrent requests`); // Open a file descriptor for the cache file const fdesc = yield fs.promises.open(archivePath, 'w+'); // Set file permissions so that other users can untar the cache yield fdesc.chmod(0o644); let progressLogger; // Configure axios-retry (0, axios_retry_1.default)(axios_1.default, { retries: 3, // No retry delay for axios-retry. shouldResetTimeout: true, retryCondition: error => { var _a; // Retry on all errors except 404. return ((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) !== 404; } }); try { core.debug(`Downloading from ${archiveLocation} to ${archivePath}`); let metadataResponse; let contentRangeHeader; let retries = 0; const maxRetries = 2; while (retries <= maxRetries) { metadataResponse = yield axios_1.default.get(archiveLocation, { headers: { Range: 'bytes=0-1' } }); contentRangeHeader = metadataResponse.headers['content-range']; if (contentRangeHeader) { break; } retries++; if (retries <= maxRetries) { core.debug(`Content-Range header not found. Retrying (${retries}/${maxRetries})...`); } } if (!contentRangeHeader) { throw new Error('Content-Range is not defined after retries; unable to determine file size'); } // Parse the total file size from the Content-Range header const fileSize = parseInt(contentRangeHeader.split('/')[1]); if (isNaN(fileSize)) { throw new Error(`Content-Range is not a number; unable to determine file size: ${contentRangeHeader}`); } core.info(`Cached file size: ${fileSize}`); // Truncate the file to the correct size yield fdesc.truncate(fileSize); yield fdesc.sync(); // Now that we've truncated the file to the correct size, we can close the file descriptor. yield fdesc.close(); progressLogger = new DownloadProgress(fileSize); progressLogger.startDisplayTimer(); core.info(`Downloading ${archivePath}`); // Divvy up the download into chunks based on CONCURRENCY const chunkSize = Math.ceil(fileSize / CONCURRENCY); const chunkRanges = []; const fileDescriptors = []; for (let i = 0; i < CONCURRENCY; i++) { const start = i * chunkSize; const end = i === CONCURRENCY - 1 ? fileSize - 1 : (i + 1) * chunkSize - 1; chunkRanges.push(`bytes=${start}-${end}`); fileDescriptors.push(yield fs.promises.open(archivePath, 'r+')); } const downloads = chunkRanges.map((range, index) => __awaiter(this, void 0, void 0, function* () { core.debug(`Downloading range: ${range}`); const response = yield axios_1.default.get(archiveLocation, { headers: { Range: range }, responseType: 'stream' }); const reportProgress = new stream.Transform({ transform(chunk, _encoding, callback) { if (progressLogger) { progressLogger.setReceivedBytes(progressLogger.getTransferredBytes() + chunk.length); } this.push(chunk); callback(); } }); const chunkFileDesc = fileDescriptors[index]; try { const finished = util.promisify(stream.finished); const writer = fs.createWriteStream(archivePath, { fd: chunkFileDesc.fd, start: parseInt(range.split('=')[1].split('-')[0]), autoClose: false }); yield response.data.pipe(reportProgress).pipe(writer); yield finished(writer); } catch (err) { core.warning(`Range ${range} failed to download: ${err.message}`); throw err; } finally { if (chunkFileDesc) { try { yield chunkFileDesc.close(); } catch (err) { core.warning(`Failed to close file descriptor: ${err}`); } } } })); yield Promise.all(downloads); } catch (err) { core.warning(`Failed to download cache: ${err.message}`); throw err; } finally { progressLogger === null || progressLogger === void 0 ? void 0 : progressLogger.stopDisplayTimer(true); } }); } exports.downloadCacheAxiosMultiPart = downloadCacheAxiosMultiPart; /** * 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 */ function downloadCacheHttpClient(archiveLocation, archivePath) { return __awaiter(this, void 0, void 0, function* () { const CONCURRENCY = 1; const fdesc = yield fs.promises.open(archivePath, 'w+'); // Set file permissions so that other users can untar the cache yield fdesc.chmod(0o644); let progressLogger; try { core.debug(`Downloading from ${archiveLocation} to ${archivePath}`); const httpClient = new http_client_1.HttpClient('useblacksmith/cache'); const metadataResponse = yield (0, requestUtils_1.retryHttpClientResponse)('downloadCache', () => __awaiter(this, void 0, void 0, function* () { return httpClient.get(archiveLocation, { Range: 'bytes=0-1' }); })); // Abort download if no traffic received over the socket. metadataResponse.message.socket.setTimeout(constants_1.SocketTimeout, () => { metadataResponse.message.destroy(); core.debug(`Aborting download, socket timed out after ${constants_1.SocketTimeout} ms`); }); const contentRangeHeader = metadataResponse.message.headers['content-range']; if (!contentRangeHeader) { throw new Error('Content-Range is not defined; unable to determine file size'); } // Parse the total file size from the Content-Range header const fileSize = parseInt(contentRangeHeader.split('/')[1]); if (isNaN(fileSize)) { throw new Error(`Content-Range is not a number; unable to determine file size: ${contentRangeHeader}`); } core.debug(`fileSize: ${fileSize}`); // Truncate the file to the correct size yield fdesc.truncate(fileSize); yield fdesc.sync(); progressLogger = new DownloadProgress(fileSize); progressLogger.startDisplayTimer(); core.info(`Downloading ${archivePath}`); // Divvy up the download into chunks based on CONCURRENCY const chunkSize = Math.ceil(fileSize / CONCURRENCY); const chunkRanges = []; for (let i = 0; i < CONCURRENCY; i++) { const start = i * chunkSize; const end = i === CONCURRENCY - 1 ? fileSize - 1 : (i + 1) * chunkSize - 1; chunkRanges.push(`bytes=${start}-${end}`); } const downloads = chunkRanges.map((range) => __awaiter(this, void 0, void 0, function* () { core.debug(`Downloading range: ${range}`); const response = yield (0, requestUtils_1.retryHttpClientResponse)('downloadCache', () => __awaiter(this, void 0, void 0, function* () { return httpClient.get(archiveLocation, { Range: range }); })); const writeStream = fs.createWriteStream(archivePath, { fd: fdesc.fd, start: parseInt(range.split('=')[1].split('-')[0]), autoClose: false }); yield pipeResponseToStream(response, writeStream, progressLogger); core.debug(`Finished downloading range: ${range}`); })); yield Promise.all(downloads); } catch (err) { core.warning(`Failed to download cache: ${err}`); throw err; } finally { // Stop the progress logger regardless of whether the download succeeded or failed. // Not doing this will cause the entire action to halt if the download fails. progressLogger === null || progressLogger === void 0 ? void 0 : progressLogger.stopDisplayTimer(); try { // NB: We're unsure why we're sometimes seeing a "EBADF: Bad file descriptor" error here. // It seems to be related to the fact that the file descriptor is closed before all // the chunks are written to it. This is a workaround to avoid the error. yield new Promise(resolve => setTimeout(resolve, 1000)); yield fdesc.close(); } catch (err) { // Intentionally swallow any errors in closing the file descriptor. core.warning(`Failed to close file descriptor: ${err}`); } } }); } exports.downloadCacheHttpClient = 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 */ function downloadCacheHttpClientConcurrent(archiveLocation, archivePath, options) { var _a; return __awaiter(this, void 0, void 0, function* () { const archiveDescriptor = yield fs.promises.open(archivePath, 'w+'); // Set file permissions so that other users can untar the cache yield archiveDescriptor.chmod(0o644); core.debug(`Downloading from ${archiveLocation} to ${archivePath}`); const httpClient = new http_client_1.HttpClient('actions/cache', undefined, { socketTimeout: options.timeoutInMs, keepAlive: true }); let progress; const stallTimeout = setTimeout(() => { reportStall(); }, 300000); stallTimeout.unref(); // Don't keep the process alive if the download is stalled. try { let metadataResponse; let contentRangeHeader; let retries = 0; const maxRetries = 2; while (retries <= maxRetries) { metadataResponse = yield (0, requestUtils_1.retryHttpClientResponse)('downloadCache', () => __awaiter(this, void 0, void 0, function* () { return httpClient.get(archiveLocation, { Range: 'bytes=0-1' }); })); // Abort download if no traffic received over the socket. metadataResponse.message.socket.setTimeout(constants_1.SocketTimeout, () => { metadataResponse.message.destroy(); core.debug(`Aborting download, socket timed out after ${constants_1.SocketTimeout} ms`); }); contentRangeHeader = metadataResponse.message.headers['content-range']; if (contentRangeHeader) { break; } retries++; if (retries <= maxRetries) { core.debug(`Content-Range header not found. Retrying (${retries}/${maxRetries})...`); } } if (!contentRangeHeader) { const headers = JSON.stringify(metadataResponse.message.headers); const statusCode = metadataResponse.message.statusCode; throw new Error(`Content-Range is not defined; unable to determine file size; Headers: ${headers}; Status Code: ${statusCode}`); } // Parse the total file size from the Content-Range header const length = parseInt(contentRangeHeader.split('/')[1]); if (isNaN(length)) { throw new Error(`Content-Range is not a number; unable to determine file size: ${contentRangeHeader}`); } progress = new DownloadProgress(length); progress.startDisplayTimer(); const downloads = []; const blockSize = 2 * 1024 * 1024; for (let offset = 0; offset < length; offset += blockSize) { const count = Math.min(blockSize, length - offset); downloads.push({ offset, promiseGetter: () => __awaiter(this, void 0, void 0, function* () { return yield downloadSegmentRetry(httpClient, archiveLocation, offset, count); }) }); } // reverse to use .pop instead of .shift downloads.reverse(); let actives = 0; let bytesDownloaded = 0; const progressFn = progress.onProgress(); const activeDownloads = []; let nextDownload; const waitAndWrite = () => __awaiter(this, void 0, void 0, function* () { const segment = yield Promise.race(Object.values(activeDownloads)); const result = yield promiseWithTimeout(10000, archiveDescriptor.write(segment.buffer, 0, segment.count, segment.offset)); if (result === 'timeout') { throw new Error('Unable to download from cache using Blacksmith Actions http-client'); } actives--; delete activeDownloads[segment.offset]; bytesDownloaded += segment.count; progressFn({ loadedBytes: bytesDownloaded }); }); while ((nextDownload = downloads.pop())) { activeDownloads[nextDownload.offset] = nextDownload.promiseGetter(); actives++; if (actives >= ((_a = options.downloadConcurrency) !== null && _a !== void 0 ? _a : 12)) { yield waitAndWrite(); } } while (actives > 0) { yield waitAndWrite(); } } finally { // Stop the progress logger regardless of whether the download succeeded or failed. if (progress) { progress.stopDisplayTimer(); } clearTimeout(stallTimeout); httpClient.dispose(); yield archiveDescriptor.close(); } }); } exports.downloadCacheHttpClientConcurrent = downloadCacheHttpClientConcurrent; function downloadSegmentRetry(httpClient, archiveLocation, offset, count) { return __awaiter(this, void 0, void 0, function* () { const retries = 5; let failures = 0; while (true) { try { const timeout = 15000; const result = yield 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++; // Jitter a bit before retrying yield new Promise(resolve => setTimeout(resolve, Math.random() * 300)); core.info(`Retrying download segment ${offset} of ${count} (${failures} of ${retries})`); } } }); } function downloadSegment(httpClient, archiveLocation, offset, count) { return __awaiter(this, void 0, void 0, function* () { const partRes = yield (0, requestUtils_1.retryHttpClientResponse)('downloadCachePart', () => __awaiter(this, void 0, void 0, function* () { return yield httpClient.get(archiveLocation, { Range: `bytes=${offset}-${offset + count - 1}` }); })); if (!partRes.readBodyBuffer) { throw new Error('Expected HttpClientResponse to implement readBodyBuffer'); } return { offset, count, buffer: yield partRes.readBodyBuffer() }; }); } /** * 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 */ function downloadCacheStorageSDK(archiveLocation, archivePath, options) { var _a; return __awaiter(this, void 0, void 0, function* () { const client = new storage_blob_1.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 = yield client.getProperties(); const contentLength = (_a = properties.contentLength) !== null && _a !== void 0 ? _a : -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...'); yield 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 abort_controller_1.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 = yield 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); } } }); } exports.downloadCacheStorageSDK = downloadCacheStorageSDK; const promiseWithTimeout = (timeoutMs, promise) => __awaiter(void 0, void 0, void 0, function* () { let timeoutHandle; const timeoutPromise = new Promise(resolve => { timeoutHandle = setTimeout(() => resolve('timeout'), timeoutMs); }); return Promise.race([promise, timeoutPromise]).then(result => { clearTimeout(timeoutHandle); return result; }); }); //# sourceMappingURL=downloadUtils.js.map