3
0
Fork 0
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:
Brenden Matthews 2025-06-20 16:35:40 -04:00
parent 7994cabd39
commit a28af779d2
No known key found for this signature in database
GPG key ID: 65458E93BD621972
12 changed files with 603 additions and 82 deletions

View file

@ -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(

View file

@ -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}`);

View file

@ -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();
}

View file

@ -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(

View file

@ -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