Skip to content

Commit 9044370

Browse files
mpecanclaude
andcommitted
fix: return 404 for stale cache entries with missing storage objects
When a cache entry exists in the database but the underlying storage object has been lost (incomplete upload, manual deletion, lifecycle policy), the download route previously threw and returned 500, causing BuildKit clients to hang instead of falling back to the upstream registry. Storage.download() now pre-validates part presence via countFilesInFolder before committing to an HTTP response, and adapters throw a typed ObjectNotFoundError when the merged blob is missing. Both are caught in the outer try/catch, which logs a warning and returns undefined so the route returns 404. Stale rows are left for the existing cleanup task to reap, avoiding mutating DB state on a read path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cf9708e commit 9044370

2 files changed

Lines changed: 153 additions & 44 deletions

File tree

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)