Skip to content

Commit fc97bc0

Browse files
mpecanclaude
andauthored
fix: return 404 for stale cache entries with missing storage objects (#223)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cf9708e commit fc97bc0

File tree

2 files changed

+153
-44
lines changed

2 files changed

+153
-44
lines changed

lib/storage.ts

Lines changed: 77 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ import { match } from 'ts-pattern'
3030
import { getDatabase } from './db'
3131
import { env } from './env'
3232
import { generateNumberId } from './helpers'
33+
import { logger } from './logger'
34+
35+
export class ObjectNotFoundError extends Error {
36+
constructor(objectName: string) {
37+
super(`Object not found in storage: ${objectName}`)
38+
this.name = 'ObjectNotFoundError'
39+
}
40+
}
3341

3442
export class Storage {
3543
adapter
@@ -208,22 +216,24 @@ export class Storage {
208216
.where('id', '=', storageLocation.id)
209217
.execute()
210218

211-
if (storageLocation.mergedAt || storageLocation.mergeStartedAt)
212-
return this.downloadFromCacheEntryLocation(storageLocation)
219+
try {
220+
if (storageLocation.mergedAt || storageLocation.mergeStartedAt)
221+
return await this.downloadFromCacheEntryLocation(storageLocation)
213222

214-
await this.db
215-
.updateTable('storage_locations')
216-
.set({
217-
mergeStartedAt: Date.now(),
218-
})
219-
.where('id', '=', storageLocation.id)
220-
.execute()
223+
await this.ensurePartsExist(storageLocation)
221224

222-
const responseStream = new PassThrough()
223-
const mergerStream = new PassThrough()
225+
await this.db
226+
.updateTable('storage_locations')
227+
.set({
228+
mergeStartedAt: Date.now(),
229+
})
230+
.where('id', '=', storageLocation.id)
231+
.execute()
224232

225-
try {
226-
const promise = this.adapter
233+
const responseStream = new PassThrough()
234+
const mergerStream = new PassThrough()
235+
236+
const mergePromise = this.adapter
227237
.uploadStream(`${storageLocation.folderName}/merged`, mergerStream)
228238
.then(async () => {
229239
await this.db
@@ -255,31 +265,36 @@ export class Storage {
255265
.execute()
256266
mergerStream.destroy()
257267
})
258-
this.mergeStreamPromises.add(promise)
259-
promise.finally(() => this.mergeStreamPromises.delete(promise))
268+
this.mergeStreamPromises.add(mergePromise)
269+
mergePromise.finally(() => this.mergeStreamPromises.delete(mergePromise))
270+
271+
this.pumpPartsToStreams(storageLocation, responseStream, mergerStream).catch((err) => {
272+
responseStream.destroy(err)
273+
mergerStream.destroy(err)
274+
if (err instanceof ObjectNotFoundError)
275+
logger.warn(`Stale cache entry ${cacheEntryId}: ${err.message}`)
276+
})
277+
278+
return responseStream
260279
} catch (err) {
261-
await this.db
262-
.updateTable('storage_locations')
263-
.set({
264-
mergedAt: null,
265-
mergeStartedAt: null,
266-
})
267-
.where('id', '=', storageLocation.id)
268-
.execute()
280+
if (err instanceof ObjectNotFoundError) {
281+
logger.warn(`Stale cache entry ${cacheEntryId}: ${err.message}`)
282+
return
283+
}
269284
throw err
270285
}
286+
}
271287

272-
this.pumpPartsToStreams(storageLocation, responseStream, mergerStream).catch((err) => {
273-
responseStream.destroy(err)
274-
mergerStream.destroy(err)
275-
})
276-
277-
return responseStream
288+
private async ensurePartsExist(location: StorageLocation) {
289+
const partsFolder = `${location.folderName}/parts`
290+
const actualPartCount = await this.adapter.countFilesInFolder(partsFolder)
291+
if (actualPartCount < location.partCount) throw new ObjectNotFoundError(partsFolder)
278292
}
279293

280294
private async downloadFromCacheEntryLocation(location: StorageLocation) {
281295
if (location.mergedAt) return this.adapter.createDownloadStream(`${location.folderName}/merged`)
282296

297+
await this.ensurePartsExist(location)
283298
return Readable.from(this.streamParts(location))
284299
}
285300

@@ -522,15 +537,20 @@ class S3Adapter implements StorageAdapter {
522537
}
523538

524539
async createDownloadStream(objectName: string) {
525-
const response = await this.s3.send(
526-
new GetObjectCommand({
527-
Bucket: this.bucket,
528-
Key: `${this.keyPrefix}/${objectName}`,
529-
}),
530-
)
531-
if (!response.Body) throw new Error('No body in S3 get object response')
540+
try {
541+
const response = await this.s3.send(
542+
new GetObjectCommand({
543+
Bucket: this.bucket,
544+
Key: `${this.keyPrefix}/${objectName}`,
545+
}),
546+
)
547+
if (!response.Body) throw new Error('No body in S3 get object response')
532548

533-
return response.Body as Readable
549+
return response.Body as Readable
550+
} catch (err: any) {
551+
if (err.name === 'NoSuchKey') throw new ObjectNotFoundError(objectName)
552+
throw err
553+
}
534554
}
535555

536556
async deleteFolder(folderName: string) {
@@ -629,7 +649,13 @@ class FileSystemAdapter implements StorageAdapter {
629649
}
630650

631651
async createDownloadStream(objectName: string) {
632-
return createReadStream(path.join(this.rootFolder, objectName))
652+
const filePath = path.join(this.rootFolder, objectName)
653+
try {
654+
await fs.access(filePath)
655+
} catch {
656+
throw new ObjectNotFoundError(objectName)
657+
}
658+
return createReadStream(filePath)
633659
}
634660

635661
async deleteFolder(folderName: string) {
@@ -656,11 +682,15 @@ class FileSystemAdapter implements StorageAdapter {
656682
}
657683

658684
async countFilesInFolder(folderName: string) {
659-
const dir = await fs.readdir(path.join(this.rootFolder, folderName), {
660-
withFileTypes: true,
661-
})
662-
663-
return dir.filter((item) => item.isFile()).length
685+
try {
686+
const dir = await fs.readdir(path.join(this.rootFolder, folderName), {
687+
withFileTypes: true,
688+
})
689+
return dir.filter((item) => item.isFile()).length
690+
} catch (err: any) {
691+
if (err.code === 'ENOENT') return 0
692+
throw err
693+
}
664694
}
665695
}
666696

@@ -690,7 +720,10 @@ class GcsAdapter implements StorageAdapter {
690720
}
691721

692722
async createDownloadStream(objectName: string) {
693-
return this.bucket.file(`${this.keyPrefix}/${objectName}`).createReadStream()
723+
const file = this.bucket.file(`${this.keyPrefix}/${objectName}`)
724+
const [exists] = await file.exists()
725+
if (!exists) throw new ObjectNotFoundError(objectName)
726+
return file.createReadStream()
694727
}
695728

696729
async deleteFolder(folderName: string) {

tests/stale-cache.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import crypto from 'node:crypto'
2+
import fs from 'node:fs/promises'
3+
import path from 'node:path'
4+
5+
import { restoreCache, saveCache } from '@actions/cache'
6+
import { SignJWT } from 'jose'
7+
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
8+
import { Storage } from '~/lib/storage'
9+
import { TEST_TEMP_DIR } from './setup'
10+
11+
const testFilePath = path.join(TEST_TEMP_DIR, 'test-stale.bin')
12+
13+
describe('stale cache entry handling (missing storage objects)', () => {
14+
let adapter: Awaited<ReturnType<typeof Storage.getAdapterFromEnv>>
15+
16+
beforeAll(async () => {
17+
process.env.ACTIONS_CACHE_SERVICE_V2 = 'true'
18+
process.env.ACTIONS_RUNTIME_TOKEN = await new SignJWT({
19+
ac: JSON.stringify([{ Scope: 'refs/heads/main', Permission: 3 }]),
20+
repository_id: '123',
21+
})
22+
.setProtectedHeader({ alg: 'HS256' })
23+
.sign(crypto.createSecretKey('mock-secret-key', 'ascii'))
24+
25+
adapter = await Storage.getAdapterFromEnv()
26+
})
27+
afterAll(() => {
28+
delete process.env.ACTIONS_CACHE_SERVICE_V2
29+
delete process.env.ACTIONS_RUNTIME_TOKEN
30+
})
31+
32+
test(
33+
'returns cache miss when parts are wiped before first download (unmerged entry)',
34+
{ timeout: 30_000 },
35+
async () => {
36+
const contents = crypto.randomBytes(1024)
37+
await fs.writeFile(testFilePath, contents)
38+
await saveCache([testFilePath], 'stale-fresh-key')
39+
await fs.rm(testFilePath)
40+
41+
await adapter.clear()
42+
43+
const missKey = await restoreCache([testFilePath], 'stale-fresh-key')
44+
expect(missKey).toBeUndefined()
45+
46+
const missKey2 = await restoreCache([testFilePath], 'stale-fresh-key')
47+
expect(missKey2).toBeUndefined()
48+
},
49+
)
50+
51+
test(
52+
'returns cache miss when the merged blob is wiped after merge completes',
53+
{ timeout: 30_000 },
54+
async () => {
55+
const contents = crypto.randomBytes(1024)
56+
await fs.writeFile(testFilePath, contents)
57+
await saveCache([testFilePath], 'stale-merged-key')
58+
await fs.rm(testFilePath)
59+
60+
const hitKey = await restoreCache([testFilePath], 'stale-merged-key')
61+
expect(hitKey).toBe('stale-merged-key')
62+
await fs.rm(testFilePath)
63+
64+
// Wait for the background merge to flush before wiping storage.
65+
await new Promise((resolve) => setTimeout(resolve, 2000))
66+
67+
await adapter.clear()
68+
69+
const missKey = await restoreCache([testFilePath], 'stale-merged-key')
70+
expect(missKey).toBeUndefined()
71+
72+
const missKey2 = await restoreCache([testFilePath], 'stale-merged-key')
73+
expect(missKey2).toBeUndefined()
74+
},
75+
)
76+
})

0 commit comments

Comments
 (0)