mirror of
https://code.forgejo.org/actions/cache.git
synced 2025-12-26 06:46:34 +00:00
Add S3 cache download validation and retry logic
- Add empty file validation (0 bytes) and minimum size checks (512 bytes) for tar archives - Implement download completeness validation (bytes downloaded = expected) - Add retry logic with exponential backoff for validation failures (3 attempts: 1s/2s/4s delays) - Create DownloadValidationError class for specific validation failures - Add comprehensive test coverage for validation scenarios - Maintain graceful degradation - validation failures log warnings but don't fail workflows
This commit is contained in:
parent
7994cabd39
commit
a28af779d2
12 changed files with 603 additions and 82 deletions
|
|
@ -154,19 +154,57 @@ export async function downloadCache(
|
|||
|
||||
const archiveUrl = new URL(archiveLocation);
|
||||
const objectKey = archiveUrl.pathname.slice(1);
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: objectKey
|
||||
});
|
||||
const url = await getSignedUrl(s3Client, command, {
|
||||
expiresIn: 3600
|
||||
});
|
||||
await downloadCacheHttpClientConcurrent(url, archivePath, {
|
||||
...options,
|
||||
downloadConcurrency: downloadQueueSize,
|
||||
concurrentBlobDownloads: true,
|
||||
partSize: downloadPartSize
|
||||
});
|
||||
|
||||
// Retry logic for download validation failures
|
||||
const maxRetries = 3;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: objectKey
|
||||
});
|
||||
const url = await getSignedUrl(s3Client, command, {
|
||||
expiresIn: 3600
|
||||
});
|
||||
|
||||
await downloadCacheHttpClientConcurrent(url, archivePath, {
|
||||
...options,
|
||||
downloadConcurrency: downloadQueueSize,
|
||||
concurrentBlobDownloads: true,
|
||||
partSize: downloadPartSize
|
||||
});
|
||||
|
||||
// If we get here, download succeeded
|
||||
return;
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
lastError = error as Error;
|
||||
|
||||
// Only retry on validation failures, not on other errors
|
||||
if (
|
||||
errorMessage.includes("Download validation failed") ||
|
||||
errorMessage.includes("Range request not supported") ||
|
||||
errorMessage.includes("Content-Range header")
|
||||
) {
|
||||
if (attempt < maxRetries) {
|
||||
const delayMs = Math.pow(2, attempt - 1) * 1000; // exponential backoff
|
||||
core.warning(
|
||||
`Download attempt ${attempt} failed: ${errorMessage}. Retrying in ${delayMs}ms...`
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// For non-retryable errors or max retries reached, throw the error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// This should never be reached, but just in case
|
||||
throw lastError || new Error("Download failed after all retry attempts");
|
||||
}
|
||||
|
||||
export async function saveCache(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@ export class ReserveCacheError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export class DownloadValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "DownloadValidationError";
|
||||
Object.setPrototypeOf(this, DownloadValidationError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
function checkPaths(paths: string[]): void {
|
||||
if (!paths || paths.length === 0) {
|
||||
throw new ValidationError(
|
||||
|
|
@ -135,6 +143,21 @@ export async function restoreCache(
|
|||
)} MB (${archiveFileSize} B)`
|
||||
);
|
||||
|
||||
// Validate downloaded archive
|
||||
if (archiveFileSize === 0) {
|
||||
throw new DownloadValidationError(
|
||||
"Downloaded cache archive is empty (0 bytes). This may indicate a failed download or corrupted cache."
|
||||
);
|
||||
}
|
||||
|
||||
// Minimum size check - a valid tar archive needs at least 512 bytes for header
|
||||
const MIN_ARCHIVE_SIZE = 512;
|
||||
if (archiveFileSize < MIN_ARCHIVE_SIZE) {
|
||||
throw new DownloadValidationError(
|
||||
`Downloaded cache archive is too small (${archiveFileSize} bytes). Expected at least ${MIN_ARCHIVE_SIZE} bytes for a valid archive.`
|
||||
);
|
||||
}
|
||||
|
||||
await extractTar(archivePath, compressionMethod);
|
||||
core.info("Cache restored successfully");
|
||||
|
||||
|
|
@ -143,6 +166,11 @@ export async function restoreCache(
|
|||
const typedError = error as Error;
|
||||
if (typedError.name === ValidationError.name) {
|
||||
throw error;
|
||||
} else if (typedError.name === DownloadValidationError.name) {
|
||||
// Log download validation errors as warnings but don't fail the workflow
|
||||
core.warning(
|
||||
`Cache download validation failed: ${typedError.message}`
|
||||
);
|
||||
} else {
|
||||
// Supress all non-validation cache related errors because caching should be optional
|
||||
core.warning(`Failed to restore: ${(error as Error).message}`);
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ export async function downloadCacheHttpClientConcurrent(
|
|||
socketTimeout: options.timeoutInMs,
|
||||
keepAlive: true
|
||||
});
|
||||
let progress: DownloadProgress | undefined;
|
||||
try {
|
||||
const res = await retryHttpClientResponse(
|
||||
"downloadCacheMetadata",
|
||||
|
|
@ -210,7 +211,7 @@ export async function downloadCacheHttpClientConcurrent(
|
|||
downloads.reverse();
|
||||
let actives = 0;
|
||||
let bytesDownloaded = 0;
|
||||
const progress = new DownloadProgress(length);
|
||||
progress = new DownloadProgress(length);
|
||||
progress.startDisplayTimer();
|
||||
const progressFn = progress.onProgress();
|
||||
|
||||
|
|
@ -246,7 +247,17 @@ export async function downloadCacheHttpClientConcurrent(
|
|||
while (actives > 0) {
|
||||
await waitAndWrite();
|
||||
}
|
||||
|
||||
// Validate that we downloaded the expected amount of data
|
||||
if (bytesDownloaded !== length) {
|
||||
throw new Error(
|
||||
`Download validation failed: Expected ${length} bytes but downloaded ${bytesDownloaded} bytes`
|
||||
);
|
||||
}
|
||||
|
||||
progress.stopDisplayTimer();
|
||||
} finally {
|
||||
progress?.stopDisplayTimer();
|
||||
httpClient.dispose();
|
||||
await archiveDescriptor.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import * as cache from "@actions/cache";
|
|||
import * as core from "@actions/core";
|
||||
|
||||
import { Events, Inputs, Outputs, State } from "./constants";
|
||||
import * as custom from "./custom/cache";
|
||||
import {
|
||||
IStateProvider,
|
||||
NullStateProvider,
|
||||
|
|
@ -9,7 +10,6 @@ import {
|
|||
} from "./stateProvider";
|
||||
import * as utils from "./utils/actionUtils";
|
||||
|
||||
import * as custom from "./custom/cache";
|
||||
const canSaveToS3 = process.env["RUNS_ON_S3_BUCKET_CACHE"] !== undefined;
|
||||
|
||||
export async function restoreImpl(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import * as cache from "@actions/cache";
|
|||
import * as core from "@actions/core";
|
||||
|
||||
import { Events, Inputs, State } from "./constants";
|
||||
import * as custom from "./custom/cache";
|
||||
import {
|
||||
IStateProvider,
|
||||
NullStateProvider,
|
||||
|
|
@ -9,7 +10,6 @@ import {
|
|||
} from "./stateProvider";
|
||||
import * as utils from "./utils/actionUtils";
|
||||
|
||||
import * as custom from "./custom/cache";
|
||||
const canSaveToS3 = process.env["RUNS_ON_S3_BUCKET_CACHE"] !== undefined;
|
||||
|
||||
// Catch and log any unhandled exceptions. These exceptions can leak out of the uploadChunk method in
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue