Skip to content

Commit 966b415

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 966b415

2 files changed

Lines changed: 173 additions & 44 deletions

File tree

lib/storage.ts

Lines changed: 86 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,30 @@ 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+
// Verify all parts are still present before committing to the HTTP response.
224+
// If the storage backend has lost the object (incomplete upload, manual
225+
// deletion, lifecycle policy), failing here bubbles ObjectNotFoundError to
226+
// the outer catch, which yields a 404. Detecting this mid-stream would
227+
// truncate an already-started 200 response, leaving clients with
228+
// unrecoverable partial data.
229+
await this.ensurePartsExist(storageLocation)
221230

222-
const responseStream = new PassThrough()
223-
const mergerStream = new PassThrough()
231+
await this.db
232+
.updateTable('storage_locations')
233+
.set({
234+
mergeStartedAt: Date.now(),
235+
})
236+
.where('id', '=', storageLocation.id)
237+
.execute()
224238

225-
try {
226-
const promise = this.adapter
239+
const responseStream = new PassThrough()
240+
const mergerStream = new PassThrough()
241+
242+
const mergePromise = this.adapter
227243
.uploadStream(`${storageLocation.folderName}/merged`, mergerStream)
228244
.then(async () => {
229245
await this.db
@@ -255,31 +271,38 @@ export class Storage {
255271
.execute()
256272
mergerStream.destroy()
257273
})
258-
this.mergeStreamPromises.add(promise)
259-
promise.finally(() => this.mergeStreamPromises.delete(promise))
274+
this.mergeStreamPromises.add(mergePromise)
275+
mergePromise.finally(() => this.mergeStreamPromises.delete(mergePromise))
276+
277+
this.pumpPartsToStreams(storageLocation, responseStream, mergerStream).catch((err) => {
278+
responseStream.destroy(err)
279+
mergerStream.destroy(err)
280+
if (err instanceof ObjectNotFoundError)
281+
logger.warn(`Stale cache entry ${cacheEntryId}: ${err.message}`)
282+
})
283+
284+
return responseStream
260285
} 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()
286+
if (err instanceof ObjectNotFoundError) {
287+
logger.warn(`Stale cache entry ${cacheEntryId}: ${err.message}`)
288+
return
289+
}
269290
throw err
270291
}
292+
}
271293

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

280300
private async downloadFromCacheEntryLocation(location: StorageLocation) {
281301
if (location.mergedAt) return this.adapter.createDownloadStream(`${location.folderName}/merged`)
282302

303+
// Parts-streaming path: the async generator is lazy, so a missing object
304+
// would only surface mid-stream. Pre-validate before returning the reader.
305+
await this.ensurePartsExist(location)
283306
return Readable.from(this.streamParts(location))
284307
}
285308

@@ -522,15 +545,20 @@ class S3Adapter implements StorageAdapter {
522545
}
523546

524547
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')
548+
try {
549+
const response = await this.s3.send(
550+
new GetObjectCommand({
551+
Bucket: this.bucket,
552+
Key: `${this.keyPrefix}/${objectName}`,
553+
}),
554+
)
555+
if (!response.Body) throw new Error('No body in S3 get object response')
532556

533-
return response.Body as Readable
557+
return response.Body as Readable
558+
} catch (err: any) {
559+
if (err.name === 'NoSuchKey') throw new ObjectNotFoundError(objectName)
560+
throw err
561+
}
534562
}
535563

536564
async deleteFolder(folderName: string) {
@@ -629,7 +657,13 @@ class FileSystemAdapter implements StorageAdapter {
629657
}
630658

631659
async createDownloadStream(objectName: string) {
632-
return createReadStream(path.join(this.rootFolder, objectName))
660+
const filePath = path.join(this.rootFolder, objectName)
661+
try {
662+
await fs.access(filePath)
663+
} catch {
664+
throw new ObjectNotFoundError(objectName)
665+
}
666+
return createReadStream(filePath)
633667
}
634668

635669
async deleteFolder(folderName: string) {
@@ -656,11 +690,16 @@ class FileSystemAdapter implements StorageAdapter {
656690
}
657691

658692
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
693+
try {
694+
const dir = await fs.readdir(path.join(this.rootFolder, folderName), {
695+
withFileTypes: true,
696+
})
697+
return dir.filter((item) => item.isFile()).length
698+
} catch (err: any) {
699+
// Missing folder reports 0, matching S3/GCS list-by-prefix semantics.
700+
if (err.code === 'ENOENT') return 0
701+
throw err
702+
}
664703
}
665704
}
666705

@@ -690,7 +729,10 @@ class GcsAdapter implements StorageAdapter {
690729
}
691730

692731
async createDownloadStream(objectName: string) {
693-
return this.bucket.file(`${this.keyPrefix}/${objectName}`).createReadStream()
732+
const file = this.bucket.file(`${this.keyPrefix}/${objectName}`)
733+
const [exists] = await file.exists()
734+
if (!exists) throw new ObjectNotFoundError(objectName)
735+
return file.createReadStream()
694736
}
695737

696738
async deleteFolder(folderName: string) {

tests/stale-cache.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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+
// Wipe backend storage before the first restore. The cache entry still
42+
// exists in the DB with mergedAt/mergeStartedAt both null, so the next
43+
// download hits the fresh-entry path that kicks off a background merge.
44+
await adapter.clear()
45+
46+
const missKey = await restoreCache([testFilePath], 'stale-fresh-key')
47+
expect(missKey).toBeUndefined()
48+
49+
// A subsequent restore must also return a miss (the stored entry is
50+
// still unreachable — self-healing is intentionally left to the
51+
// cleanup job, so the observable behavior is just a stable 404).
52+
const missKey2 = await restoreCache([testFilePath], 'stale-fresh-key')
53+
expect(missKey2).toBeUndefined()
54+
},
55+
)
56+
57+
test(
58+
'returns cache miss when the merged blob is wiped after merge completes',
59+
{ timeout: 30_000 },
60+
async () => {
61+
const contents = crypto.randomBytes(1024)
62+
await fs.writeFile(testFilePath, contents)
63+
await saveCache([testFilePath], 'stale-merged-key')
64+
await fs.rm(testFilePath)
65+
66+
// First restore triggers the background merge via the fresh-entry path.
67+
const hitKey = await restoreCache([testFilePath], 'stale-merged-key')
68+
expect(hitKey).toBe('stale-merged-key')
69+
await fs.rm(testFilePath)
70+
71+
// Allow the server-side merge (uploadStream -> setMergedAt -> delete
72+
// parts) to finish. restoreCache returning only guarantees the response
73+
// was consumed, not that the merger upload has flushed.
74+
await new Promise((resolve) => setTimeout(resolve, 2000))
75+
76+
// Wipe backend storage. The DB row now has mergedAt set, so the next
77+
// download hits the merged-blob path.
78+
await adapter.clear()
79+
80+
const missKey = await restoreCache([testFilePath], 'stale-merged-key')
81+
expect(missKey).toBeUndefined()
82+
83+
const missKey2 = await restoreCache([testFilePath], 'stale-merged-key')
84+
expect(missKey2).toBeUndefined()
85+
},
86+
)
87+
})

0 commit comments

Comments
 (0)