Repository: actions/toolkit
Pull request #1286 (2022-12-23 11:59:31)

Add cache restore `lookupOnly` option
This PR adds a `lookupOnly` option to the cache restore function.
It can be used to check if a cache exists without the need to download it.

Especially useful if e.g. dependency install and actual tests are split across multiple jobs. If the cache restore step for the dependency install has an exact hit, the remaining steps in the job can be skipped. Similarly if the cache is created from scratch and it's just necessary to know if a cache entry already exists.

Would help address
https://github.com/actions/cache/issues/901
https://github.com/actions/cache/issues/831
https://github.com/actions/cache/discussions/1020#discussioncomment-4332778
https://github.com/actions/cache/discussions/1020#discussioncomment-4341251

Alternative to #1207

CC: @kotewar 


Patch:
diff --git a/packages/cache/RELEASES.md b/packages/cache/RELEASES.md
index 76bec60..424b768 100644
--- a/packages/cache/RELEASES.md
+++ b/packages/cache/RELEASES.md
@@ -117,4 +117,7 @@
 - Fix to prevent from setting MYSYS environement variable globally [#1329](https://github.com/actions/toolkit/pull/1329).
 
 ### 3.1.4
-- Fix zstd not being used due to `zstd --version` output change in zstd 1.5.4 release. See [#1353](https://github.com/actions/toolkit/pull/1353).
\ No newline at end of file
+- Fix zstd not being used due to `zstd --version` output change in zstd 1.5.4 release. See [#1353](https://github.com/actions/toolkit/pull/1353).
+
+### 3.2.0
+- Add `lookupOnly` to cache restore `DownloadOptions`.
diff --git a/packages/cache/package-lock.json b/packages/cache/package-lock.json
index 408660c..0d568ff 100644
--- a/packages/cache/package-lock.json
+++ b/packages/cache/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@actions/cache",
-  "version": "3.1.4",
+  "version": "3.2.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "@actions/cache",
-      "version": "3.1.4",
+      "version": "3.2.0",
       "license": "MIT",
       "dependencies": {
         "@actions/core": "^1.10.0",
diff --git a/packages/cache/package.json b/packages/cache/package.json
index 3d5dda6..b411de1 100644
--- a/packages/cache/package.json
+++ b/packages/cache/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@actions/cache",
-  "version": "3.1.4",
+  "version": "3.2.0",
   "preview": true,
   "description": "Actions cache lib",
   "keywords": [
diff --git a/packages/cache/src/cache.ts b/packages/cache/src/cache.ts
index ffa13e7..f7fadb6 100644
--- a/packages/cache/src/cache.ts
+++ b/packages/cache/src/cache.ts
@@ -100,6 +100,11 @@ export async function restoreCache(
       return undefined
     }
 
+    if (options?.lookupOnly) {
+      core.info('Lookup only - skipping download')
+      return cacheEntry.cacheKey
+    }
+
     archivePath = path.join(
       await utils.createTempDirectory(),
       utils.getCacheFileName(compressionMethod)
diff --git a/packages/cache/src/options.ts b/packages/cache/src/options.ts
index 652899a..cdddeec 100644
--- a/packages/cache/src/options.ts
+++ b/packages/cache/src/options.ts
@@ -53,6 +53,15 @@ export interface DownloadOptions {
    * @default 3600000
    */
   segmentTimeoutInMs?: number
+
+  /**
+   * Weather to skip downloading the cache entry.
+   * If lookupOnly is set to true, the restore function will only check if
+   * a matching cache entry exists and return the cache key if it does.
+   *
+   * @default false
+   */
+  lookupOnly?: boolean
 }
 
 /**
@@ -92,7 +101,8 @@ export function getDownloadOptions(copy?: DownloadOptions): DownloadOptions {
     useAzureSdk: true,
     downloadConcurrency: 8,
     timeoutInMs: 30000,
-    segmentTimeoutInMs: 3600000
+    segmentTimeoutInMs: 3600000,
+    lookupOnly: false
   }
 
   if (copy) {
@@ -111,6 +121,10 @@ export function getDownloadOptions(copy?: DownloadOptions): DownloadOptions {
     if (typeof copy.segmentTimeoutInMs === 'number') {
       result.segmentTimeoutInMs = copy.segmentTimeoutInMs
     }
+
+    if (typeof copy.lookupOnly === 'boolean') {
+      result.lookupOnly = copy.lookupOnly
+    }
   }
   const segmentDownloadTimeoutMins =
     process.env['SEGMENT_DOWNLOAD_TIMEOUT_MINS']
@@ -129,6 +143,7 @@ export function getDownloadOptions(copy?: DownloadOptions): DownloadOptions {
     `Cache segment download timeout mins env var: ${process.env['SEGMENT_DOWNLOAD_TIMEOUT_MINS']}`
   )
   core.debug(`Segment download timeout (ms): ${result.segmentTimeoutInMs}`)
+  core.debug(`Lookup only: ${result.lookupOnly}`)
 
   return result
 }


Test patch:
diff --git a/packages/cache/__tests__/options.test.ts b/packages/cache/__tests__/options.test.ts
index 83d9b22..79f0424 100644
--- a/packages/cache/__tests__/options.test.ts
+++ b/packages/cache/__tests__/options.test.ts
@@ -9,6 +9,7 @@ const useAzureSdk = true
 const downloadConcurrency = 8
 const timeoutInMs = 30000
 const segmentTimeoutInMs = 3600000
+const lookupOnly = false
 const uploadConcurrency = 4
 const uploadChunkSize = 32 * 1024 * 1024
 
@@ -19,7 +20,8 @@ test('getDownloadOptions sets defaults', async () => {
     useAzureSdk,
     downloadConcurrency,
     timeoutInMs,
-    segmentTimeoutInMs
+    segmentTimeoutInMs,
+    lookupOnly
   })
 })
 
@@ -28,7 +30,8 @@ test('getDownloadOptions overrides all settings', async () => {
     useAzureSdk: false,
     downloadConcurrency: 14,
     timeoutInMs: 20000,
-    segmentTimeoutInMs: 3600000
+    segmentTimeoutInMs: 3600000,
+    lookupOnly: true
   }
 
   const actualOptions = getDownloadOptions(expectedOptions)
@@ -61,7 +64,8 @@ test('getDownloadOptions overrides download timeout minutes', async () => {
     useAzureSdk: false,
     downloadConcurrency: 14,
     timeoutInMs: 20000,
-    segmentTimeoutInMs: 3600000
+    segmentTimeoutInMs: 3600000,
+    lookupOnly: true
   }
   process.env.SEGMENT_DOWNLOAD_TIMEOUT_MINS = '10'
   const actualOptions = getDownloadOptions(expectedOptions)
@@ -72,4 +76,5 @@ test('getDownloadOptions overrides download timeout minutes', async () => {
   )
   expect(actualOptions.timeoutInMs).toEqual(expectedOptions.timeoutInMs)
   expect(actualOptions.segmentTimeoutInMs).toEqual(600000)
+  expect(actualOptions.lookupOnly).toEqual(expectedOptions.lookupOnly)
 })
diff --git a/packages/cache/__tests__/restoreCache.test.ts b/packages/cache/__tests__/restoreCache.test.ts
index 5318a00..7992490 100644
--- a/packages/cache/__tests__/restoreCache.test.ts
+++ b/packages/cache/__tests__/restoreCache.test.ts
@@ -276,3 +276,39 @@ test('restore with cache found for restore key', async () => {
   expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression)
   expect(getCompressionMock).toHaveBeenCalledTimes(1)
 })
+
+test('restore with dry run', async () => {
+  const paths = ['node_modules']
+  const key = 'node-test'
+  const options = {lookupOnly: true}
+
+  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 createTempDirectoryMock = jest.spyOn(cacheUtils, 'createTempDirectory')
+  const downloadCacheMock = jest.spyOn(cacheHttpClient, 'downloadCache')
+
+  const compression = CompressionMethod.Gzip
+  const getCompressionMock = jest
+    .spyOn(cacheUtils, 'getCompressionMethod')
+    .mockReturnValue(Promise.resolve(compression))
+
+  const cacheKey = await restoreCache(paths, key, undefined, options)
+
+  expect(cacheKey).toBe(key)
+  expect(getCompressionMock).toHaveBeenCalledTimes(1)
+  expect(getCacheMock).toHaveBeenCalledWith([key], paths, {
+    compressionMethod: compression,
+    enableCrossOsArchive: false
+  })
+  // creating a tempDir and downloading the cache are skipped
+  expect(createTempDirectoryMock).toHaveBeenCalledTimes(0)
+  expect(downloadCacheMock).toHaveBeenCalledTimes(0)
+})