Skip to content

Commit 2e10a30

Browse files
committed
feat: github cache service v2 support
1 parent 8e97eda commit 2e10a30

File tree

8 files changed

+216
-24
lines changed

8 files changed

+216
-24
lines changed

lib/db/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,12 @@ export async function pruneKeys(db: DB, keys?: Selectable<CacheKeysTable>[]) {
247247
}
248248
}
249249

250-
export async function uploadExists(db: DB, { key, version }: { key: string; version: string }) {
250+
export async function getUpload(db: DB, { key, version }: { key: string; version: string }) {
251251
const row = await db
252252
.selectFrom('uploads')
253253
.select('id')
254254
.where('key', '=', key)
255255
.where('version', '=', version)
256256
.executeTakeFirst()
257-
return !!row
257+
return row
258258
}

lib/storage/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import consola from 'consola'
77
import {
88
findKeyMatch,
99
findStaleKeys,
10+
getUpload,
1011
pruneKeys,
1112
touchKey,
1213
updateOrCreateKey,
13-
uploadExists,
1414
useDB,
1515
} from '~/lib/db'
1616
import { ENV } from '~/lib/env'
@@ -24,7 +24,7 @@ export interface Storage {
2424
keys: string[],
2525
version: string,
2626
) => Promise<{
27-
cacheKey?: string
27+
cacheKey: string
2828
archiveLocation: string
2929
} | null>
3030
download: (objectName: string) => Promise<ReadableStream | Readable>
@@ -34,7 +34,7 @@ export interface Storage {
3434
chunkStart: number,
3535
chunkEnd: number,
3636
) => Promise<void>
37-
commitCache: (uploadId: number, size: number) => Promise<void>
37+
commitCache: (uploadId: number | string, size: number) => Promise<void>
3838
reserveCache: (
3939
key: string,
4040
version: string,
@@ -71,7 +71,7 @@ export async function initializeStorage() {
7171
async reserveCache(key, version, totalSize) {
7272
logger.debug('Reserve:', { key, version })
7373

74-
if (await uploadExists(db, { key, version })) {
74+
if (await getUpload(db, { key, version })) {
7575
logger.debug(`Reserve: Already reserved. Ignoring...`, { key, version })
7676
return {
7777
cacheId: null,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { z } from 'zod'
2+
import { ENV } from '~/lib/env'
3+
import { useStorageAdapter } from '~/lib/storage'
4+
5+
const bodySchema = z.object({
6+
key: z.string(),
7+
version: z.string(),
8+
})
9+
10+
export default defineEventHandler(async (event) => {
11+
const body = (await readBody(event)) as unknown
12+
const parsedBody = bodySchema.safeParse(body)
13+
if (!parsedBody.success)
14+
throw createError({
15+
statusCode: 400,
16+
statusMessage: `Invalid body: ${parsedBody.error.message}`,
17+
})
18+
19+
const { key, version } = parsedBody.data
20+
21+
const adapter = await useStorageAdapter()
22+
const reservation = await adapter.reserveCache(key, version)
23+
if (!reservation.cacheId)
24+
return {
25+
ok: false,
26+
}
27+
28+
return {
29+
ok: true,
30+
signed_upload_url: `${ENV.API_BASE_URL}/upload/${reservation.cacheId}`,
31+
}
32+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { z } from 'zod'
2+
import { getUpload, useDB } from '~/lib/db'
3+
import { useStorageAdapter } from '~/lib/storage'
4+
5+
const bodySchema = z.object({
6+
key: z.string(),
7+
size_bytes: z.coerce.number(),
8+
version: z.string(),
9+
})
10+
11+
export default defineEventHandler(async (event) => {
12+
const parsedBody = bodySchema.safeParse(await readBody(event))
13+
if (!parsedBody.success)
14+
throw createError({
15+
statusCode: 400,
16+
statusMessage: `Invalid body: ${parsedBody.error.message}`,
17+
})
18+
19+
const { key, size_bytes, version } = parsedBody.data
20+
21+
const db = await useDB()
22+
const adapter = await useStorageAdapter()
23+
const upload = await getUpload(db, { key, version })
24+
if (!upload)
25+
throw createError({
26+
statusCode: 404,
27+
statusMessage: 'Upload not found',
28+
})
29+
30+
await adapter.commitCache(upload.id, size_bytes)
31+
32+
return {
33+
ok: true,
34+
entry_id: upload.id,
35+
}
36+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { z } from 'zod'
2+
import { useStorageAdapter } from '~/lib/storage'
3+
4+
const bodySchema = z.object({
5+
key: z.string(),
6+
restore_keys: z.array(z.string()).nullish().optional(),
7+
version: z.string(),
8+
})
9+
10+
export default defineEventHandler(async (event) => {
11+
const parsedBody = bodySchema.safeParse(await readBody(event))
12+
if (!parsedBody.success)
13+
throw createError({
14+
statusCode: 400,
15+
statusMessage: `Invalid body: ${parsedBody.error.message}`,
16+
})
17+
18+
const { key, restore_keys, version } = parsedBody.data
19+
const adapter = await useStorageAdapter()
20+
const storageEntry = await adapter.getCacheEntry([key, ...(restore_keys ?? [])], version)
21+
22+
if (!storageEntry)
23+
return {
24+
ok: false,
25+
}
26+
27+
return {
28+
ok: true,
29+
signed_download_url: storageEntry.archiveLocation,
30+
matched_key: storageEntry.cacheKey,
31+
}
32+
})

routes/upload/[cacheId].put.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Buffer } from 'node:buffer'
2+
3+
import { z } from 'zod'
4+
import { logger } from '~/lib/logger'
5+
6+
import { useStorageAdapter } from '~/lib/storage'
7+
8+
// https://github.com/actions/toolkit/blob/340a6b15b5879eefe1412ee6c8606978b091d3e8/packages/cache/src/cache.ts#L470
9+
const chunkSize = 64 * 1024 * 1024
10+
11+
const pathParamsSchema = z.object({
12+
cacheId: z.coerce.number(),
13+
})
14+
15+
const sizeByBlockId = new Map<string, number>()
16+
17+
export default defineEventHandler(async (event) => {
18+
const parsedPathParams = pathParamsSchema.safeParse(event.context.params)
19+
if (!parsedPathParams.success)
20+
throw createError({
21+
statusCode: 400,
22+
statusMessage: `Invalid path parameters: ${parsedPathParams.error.message}`,
23+
})
24+
25+
if (getQuery(event).comp === 'blocklist') {
26+
setResponseStatus(event, 201)
27+
return 'ok'
28+
}
29+
30+
const blockId = getQuery(event)?.blockid as string
31+
// if no block id, upload smaller than chunk size
32+
const chunkIndex = blockId ? getChunkIndexFromBlockId(blockId) : 0
33+
if (chunkIndex === undefined)
34+
throw createError({
35+
statusCode: 400,
36+
statusMessage: `Invalid block id: ${blockId}`,
37+
})
38+
39+
const { cacheId } = parsedPathParams.data
40+
41+
const stream = getRequestWebStream(event)
42+
if (!stream) {
43+
logger.debug('Upload: Request body is not a stream')
44+
throw createError({ statusCode: 400, statusMessage: 'Request body must be a stream' })
45+
}
46+
47+
const contentLengthHeader = getHeader(event, 'content-length')
48+
const contentLength = Number.parseInt(contentLengthHeader ?? '')
49+
if (!contentLengthHeader || Number.isNaN(contentLength)) {
50+
logger.debug("Upload: 'content-length' header not found")
51+
throw createError({ statusCode: 400, statusMessage: "'content-length' header is required" })
52+
}
53+
54+
sizeByBlockId.set(blockId, contentLength)
55+
const start = chunkIndex * chunkSize
56+
const end = start + contentLength - 1
57+
58+
const adapter = await useStorageAdapter()
59+
await adapter.uploadChunk(cacheId, stream as ReadableStream<Buffer>, start, end)
60+
61+
setResponseStatus(event, 201)
62+
})
63+
64+
/**
65+
* Format (base64 decoded): 06a9ffa8-2e62-4e96-8e5b-15f24c117f1f000000000006
66+
*/
67+
function getChunkIndexFromBlockId(blockId: string) {
68+
const decoded = Buffer.from(blockId, 'base64').toString('utf8')
69+
if (decoded.length !== 48) return
70+
71+
// slice off uuid and convert to number
72+
const index = Number.parseInt(decoded.slice(36))
73+
if (Number.isNaN(index)) return
74+
75+
return index
76+
}

tests/.env.base

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ API_BASE_URL=http://localhost:3000
22
NODE_ENV=development
33
RUNNER_TEMP=tests/temp/runner
44
ACTIONS_CACHE_URL=http://localhost:3000/
5+
ACTIONS_RESULTS_URL=http://localhost:3000/

tests/e2e.test.ts

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,44 @@ import fs from 'node:fs/promises'
33
import path from 'node:path'
44

55
import { restoreCache, saveCache } from '@actions/cache'
6-
import { describe, expect, test } from 'vitest'
6+
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
77

88
const TEST_TEMP_DIR = path.join(import.meta.dirname, 'temp')
99
await fs.mkdir(TEST_TEMP_DIR, { recursive: true })
1010
const testFilePath = path.join(TEST_TEMP_DIR, 'test.bin')
1111

1212
const MB = 1024 * 1024
1313

14-
describe('save and restore cache with @actions/cache package', () => {
15-
for (const size of [5 * MB, 50 * MB, 500 * MB, 1024 * MB])
16-
test(`${size} Bytes`, { timeout: 90_000 }, async () => {
17-
// save
18-
const expectedContents = crypto.randomBytes(size)
19-
await fs.writeFile(testFilePath, expectedContents)
20-
await saveCache([testFilePath], 'cache-key')
21-
await fs.rm(testFilePath)
22-
23-
// restore
24-
const cacheHitKey = await restoreCache([testFilePath], 'cache-key')
25-
expect(cacheHitKey).toBe('cache-key')
26-
27-
// check contents
28-
const restoredContents = await fs.readFile(testFilePath)
29-
expect(restoredContents.compare(expectedContents)).toBe(0)
14+
const versions = ['v2', 'v1'] as const
15+
16+
for (const version of versions) {
17+
describe(`save and restore cache with @actions/cache package with api ${version}`, () => {
18+
beforeAll(() => {
19+
if (version !== 'v2') return
20+
21+
process.env.ACTIONS_CACHE_SERVICE_V2 = 'true'
22+
process.env.ACTIONS_RUNTIME_TOKEN = 'mock-runtime-token'
23+
})
24+
afterAll(() => {
25+
delete process.env.ACTIONS_CACHE_SERVICE_V2
26+
delete process.env.ACTIONS_RUNTIME_TOKEN
3027
})
31-
})
28+
29+
for (const size of [5 * MB, 50 * MB, 500 * MB, 1024 * MB])
30+
test(`${size} Bytes`, { timeout: 90_000 }, async () => {
31+
// save
32+
const expectedContents = crypto.randomBytes(size)
33+
await fs.writeFile(testFilePath, expectedContents)
34+
await saveCache([testFilePath], 'cache-key')
35+
await fs.rm(testFilePath)
36+
37+
// restore
38+
const cacheHitKey = await restoreCache([testFilePath], 'cache-key')
39+
expect(cacheHitKey).toBe('cache-key')
40+
41+
// check contents
42+
const restoredContents = await fs.readFile(testFilePath)
43+
expect(restoredContents.compare(expectedContents)).toBe(0)
44+
})
45+
})
46+
}

0 commit comments

Comments
 (0)