@useblacksmith/cache
Version:
Blacksmith Actions cache lib
641 lines • 29.2 kB
JavaScript
;
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