From a80f39f34c0d0cb73cd87e99a1a60d49e60355d9 Mon Sep 17 00:00:00 2001 From: Martijn Hols Date: Tue, 1 Dec 2020 22:53:33 +0100 Subject: [PATCH 1/2] Add getCacheEntry Provides a way to determine if the cache exists without being required to download it. This can be used to skip jobs given that the result is already cached. --- .../cache/__tests__/getCacheEntry.test.ts | 169 ++++++++++++++++++ packages/cache/src/cache.ts | 44 ++++- 2 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 packages/cache/__tests__/getCacheEntry.test.ts diff --git a/packages/cache/__tests__/getCacheEntry.test.ts b/packages/cache/__tests__/getCacheEntry.test.ts new file mode 100644 index 0000000000..81fb05423a --- /dev/null +++ b/packages/cache/__tests__/getCacheEntry.test.ts @@ -0,0 +1,169 @@ +import * as core from '@actions/core' +import {getCacheEntry} from '../src/cache' +import * as cacheHttpClient from '../src/internal/cacheHttpClient' +import {CompressionMethod} from '../src/internal/constants' +import {ArtifactCacheEntry} from '../src/internal/contracts' + +jest.mock('../src/internal/cacheHttpClient') +jest.mock('../src/internal/cacheUtils') + +beforeAll(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}) + jest.spyOn(core, 'debug').mockImplementation(() => {}) + jest.spyOn(core, 'info').mockImplementation(() => {}) + jest.spyOn(core, 'warning').mockImplementation(() => {}) + jest.spyOn(core, 'error').mockImplementation(() => {}) +}) + +test('getCacheEntry with no path should fail', async () => { + const paths: string[] = [] + const key = 'node-test' + await expect(getCacheEntry(paths, key)).rejects.toThrowError( + `Path Validation Error: At least one directory or file path is required` + ) +}) + +test('getCacheEntry with too many keys should fail', async () => { + const paths = ['node_modules'] + const key = 'node-test' + const restoreKeys = [...Array(20).keys()].map(x => x.toString()) + await expect( + getCacheEntry(paths, key, restoreKeys, CompressionMethod.Zstd) + ).rejects.toThrowError( + `Key Validation Error: Keys are limited to a maximum of 10.` + ) +}) + +test('getCacheEntry with large key should fail', async () => { + const paths = ['node_modules'] + const key = 'foo'.repeat(512) // Over the 512 character limit + await expect(getCacheEntry(paths, key)).rejects.toThrowError( + `Key Validation Error: ${key} cannot be larger than 512 characters.` + ) +}) + +test('getCacheEntry with invalid key should fail', async () => { + const paths = ['node_modules'] + const key = 'comma,comma' + await expect(getCacheEntry(paths, key)).rejects.toThrowError( + `Key Validation Error: ${key} cannot contain commas.` + ) +}) + +test('getCacheEntry with no cache found', async () => { + const paths = ['node_modules'] + const key = 'node-test' + + jest.spyOn(cacheHttpClient, 'getCacheEntry').mockImplementation(async () => { + return Promise.resolve(null) + }) + + const cacheEntry = await getCacheEntry(paths, key) + + expect(cacheEntry).toBe(null) +}) + +test('getCacheEntry with server error should fail', async () => { + const paths = ['node_modules'] + const key = 'node-test' + + jest.spyOn(cacheHttpClient, 'getCacheEntry').mockImplementation(() => { + throw new Error('HTTP Error Occurred') + }) + + await expect(getCacheEntry(paths, key)).rejects.toThrowError( + 'HTTP Error Occurred' + ) +}) + +test('getCacheEntry with restore keys and no cache found', async () => { + const paths = ['node_modules'] + const key = 'node-test' + const restoreKey = 'node-' + + jest.spyOn(cacheHttpClient, 'getCacheEntry').mockImplementation(async () => { + return Promise.resolve(null) + }) + + const cacheEntry = await getCacheEntry( + paths, + key, + [restoreKey], + CompressionMethod.Zstd + ) + + expect(cacheEntry).toBe(null) +}) + +test('getCacheEntry with gzip compressed cache found', async () => { + const paths = ['node_modules'] + const key = 'node-test' + + const cacheEntry: ArtifactCacheEntry = { + cacheKey: key, + scope: 'refs/heads/main', + archiveLocation: 'www.actionscache.test/download' + } + const getCacheMock = jest.spyOn(cacheHttpClient, 'getCacheEntry') + getCacheMock.mockImplementation(async () => { + return Promise.resolve(cacheEntry) + }) + + const compression = CompressionMethod.Gzip + + const result = await getCacheEntry(paths, key, undefined, compression) + + expect(result).toBe(cacheEntry) + expect(getCacheMock).toHaveBeenCalledWith([key], paths, { + compressionMethod: compression + }) +}) + +test('getCacheEntry with zstd compressed cache found', async () => { + const paths = ['node_modules'] + const key = 'node-test' + + const cacheEntry: ArtifactCacheEntry = { + cacheKey: key, + scope: 'refs/heads/main', + archiveLocation: 'www.actionscache.test/download' + } + const getCacheMock = jest.spyOn(cacheHttpClient, 'getCacheEntry') + getCacheMock.mockImplementation(async () => { + return Promise.resolve(cacheEntry) + }) + + const compression = CompressionMethod.Zstd + + const result = await getCacheEntry(paths, key, undefined, compression) + + expect(result).toBe(cacheEntry) + expect(getCacheMock).toHaveBeenCalledWith([key], paths, { + compressionMethod: compression + }) +}) + +test('getCacheEntry with cache found for restore key', async () => { + const paths = ['node_modules'] + const key = 'node-test' + const restoreKey = 'node-' + + const cacheEntry: ArtifactCacheEntry = { + cacheKey: restoreKey, + scope: 'refs/heads/main', + archiveLocation: 'www.actionscache.test/download' + } + const getCacheMock = jest.spyOn(cacheHttpClient, 'getCacheEntry') + getCacheMock.mockImplementation(async () => { + return Promise.resolve(cacheEntry) + }) + + const compression = CompressionMethod.Zstd + + const result = await getCacheEntry(paths, key, [restoreKey], compression) + + expect(result).toBe(cacheEntry) + expect(getCacheMock).toHaveBeenCalledWith([key, restoreKey], paths, { + compressionMethod: compression + }) +}) diff --git a/packages/cache/src/cache.ts b/packages/cache/src/cache.ts index 57ea1527bf..3a1d8ba6fe 100644 --- a/packages/cache/src/cache.ts +++ b/packages/cache/src/cache.ts @@ -2,6 +2,8 @@ import * as core from '@actions/core' import * as path from 'path' import * as utils from './internal/cacheUtils' import * as cacheHttpClient from './internal/cacheHttpClient' +import {CompressionMethod} from './internal/constants' +import {ArtifactCacheEntry} from './internal/contracts' import {createTar, extractTar} from './internal/tar' import {DownloadOptions, UploadOptions} from './options' @@ -44,20 +46,20 @@ function checkKey(key: string): void { } /** - * Restores cache from keys + * Get the cache entry from keys * * @param paths a list of file paths to restore from the cache * @param primaryKey an explicit key for restoring the cache * @param restoreKeys an optional ordered list of keys to use for restoring the cache if no cache hit occurred for key - * @param downloadOptions cache download options - * @returns string returns the key for the cache hit, otherwise returns undefined + * @param compressionMethod cache compression method + * @returns string returns the cache entry for the cache hit, otherwise returns null */ -export async function restoreCache( +export async function getCacheEntry( paths: string[], primaryKey: string, restoreKeys?: string[], - options?: DownloadOptions -): Promise { + compressionMethod?: CompressionMethod +): Promise { checkPaths(paths) restoreKeys = restoreKeys || [] @@ -75,12 +77,36 @@ export async function restoreCache( checkKey(key) } + // path are needed to compute version + return await cacheHttpClient.getCacheEntry(keys, paths, { + compressionMethod: compressionMethod ?? (await utils.getCompressionMethod()) + }) +} + +/** + * Restores cache from keys + * + * @param paths a list of file paths to restore from the cache + * @param primaryKey an explicit key for restoring the cache + * @param restoreKeys an optional ordered list of keys to use for restoring the cache if no cache hit occurred for key + * @param downloadOptions cache download options + * @returns string returns the key for the cache hit, otherwise returns undefined + */ +export async function restoreCache( + paths: string[], + primaryKey: string, + restoreKeys?: string[], + options?: DownloadOptions +): Promise { const compressionMethod = await utils.getCompressionMethod() - // path are needed to compute version - const cacheEntry = await cacheHttpClient.getCacheEntry(keys, paths, { + const cacheEntry = await getCacheEntry( + paths, + primaryKey, + restoreKeys, compressionMethod - }) + ) + if (!cacheEntry?.archiveLocation) { // Cache not found return undefined From 83a62b1bfe3e4972e8bcbe1505566c1dcf85666c Mon Sep 17 00:00:00 2001 From: Martijn Hols Date: Wed, 2 Dec 2020 17:35:20 +0100 Subject: [PATCH 2/2] Fix type error in consumers: contracts file was missing --- packages/cache/src/internal/{contracts.d.ts => contracts.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/cache/src/internal/{contracts.d.ts => contracts.ts} (100%) diff --git a/packages/cache/src/internal/contracts.d.ts b/packages/cache/src/internal/contracts.ts similarity index 100% rename from packages/cache/src/internal/contracts.d.ts rename to packages/cache/src/internal/contracts.ts